diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml similarity index 67% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml index 10665d6e64..e49130d5ee 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md -2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68 -2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44 +2026-09-16-movable-mandatory-update-window.md: da996e5b37f990ff773e3ec88de0baed3c4b54f3 +2026-09-16-movable-mandatory-update-window.zh.md: 15fd4eb2520a39d8259321a747b33c833b56cbb1 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md index 4695074429..da996e5b37 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md @@ -1,6 +1,7 @@ # Agent Note: Movable mandatory-update window Status: implemented +Archived: 2026-09-18 English | [中文](2026-09-16-movable-mandatory-update-window.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md index 3b416c53dc..15fd4eb252 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md @@ -1,6 +1,7 @@ # Agent Note: 可移动的强更窗口 Status: implemented +Archived: 2026-09-18 [English](2026-09-16-movable-mandatory-update-window.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 88573f7db2..74d114c6bb 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -751,6 +751,9 @@ "bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c", "bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80", "bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2", + "bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml": "sha256:e526ce14ae9557e24364e3686778b130090445ef2152d976b7cc93e76eac6401", + "bug-fix/2026-09-16-movable-mandatory-update-window.md": "sha256:67546cea0b3de2b3e32957a508dbde097d91dd919fe067c8dbecb2c65b50c366", + "bug-fix/2026-09-16-movable-mandatory-update-window.zh.md": "sha256:20a0aade6ee71c89e84d70e23c89bb51e8a38c674d103dd43022feabb8d27be3", "bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml": "sha256:4442529ced1c8e48d30f7b50c65ff03eb92dce9815beb438cedbf29f234bce47", "bug-fix/2026-09-11-session-controller-fork-turn-cut.md": "sha256:a996f24a896c7fc5d81e015db3eae27d8a46c853287572e815623ce35eae1b12", "bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md": "sha256:6486f0eacf5067ef9850dc32f1c27b2109a7d4e9e3c498366f5c38742bb53ac5", diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index ad8ffb6167..04fa599e6f 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415 -2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61 +2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1 +2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 273b51ac7c..e21793e0ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -131,7 +131,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr ### Parent catalog prerequisites -V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). +Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 69132ec176..637d8fcf02 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -131,7 +131,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o ### 父目录前置事实 -V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 +本段的运行时子会话错误处理部分由[逐会话目录准备](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;新增完整记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml index e63305ebf1..106b30af30 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md -2026-09-15-messages-v1-base-url.md: 97f7294c28988e6f115963033cdac6794d70d6f3 -2026-09-15-messages-v1-base-url.zh.md: ccb8c5930dbbfa6274770cc03087b4d8077fe27b +2026-09-15-messages-v1-base-url.md: d9014756eacd1752495bfecb52860634ece08ae1 +2026-09-15-messages-v1-base-url.zh.md: 8f5803e4bdc68195164e187d1aa8cd537f71c5d6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md index 97f7294c28..d9014756ea 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md @@ -12,7 +12,7 @@ The Messages transport appends the Anthropic-standard `/v1` namespace to a confi The shared Messages API owner trims trailing slashes and treats only a final path segment exactly equal to `v1` as the existing API version. It preserves that root and appends `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The same resolved root scopes cached file uploads. The official `https://api.deepseek.com/anthropic` base therefore resolves to `/anthropic/v1`, and an explicit `/anthropic/v1` base remains unchanged. -Chat Completions retains its independent URL behavior. The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them. +The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md index ccb8c5930d..8f5803e4bd 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md @@ -12,7 +12,7 @@ Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空 共享 Messages API 所有者移除末尾斜线,仅把严格等于 `v1` 的最后路径段视为已有 API 版本。它保留该根地址并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。缓存文件上传也使用同一个解析后的根地址划分作用域。因此,官方 `https://api.deepseek.com/anthropic` 基址解析为 `/anthropic/v1`,显式 `/anthropic/v1` 基址保持不变。 -Chat Completions 保留独立的 URL 行为。Messages 规则不会推断对 `v1beta`、`v2`、`v4` 或其他版本式后缀的支持;包含这些路径段的部署会在其下获得标准 `/v1` 命名空间。 +Messages 规则不推断 `v1beta`、`v2`、`v4` 或其他类似版本后缀的支持;包含这些路径段的部署会在其下接收标准 `/v1` 命名空间。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml index 6bb2fcf803..e6974943f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md -2026-09-16-messages-historical-tool-input.md: 201b0f21ad041395a4c4101d6919b2878e22c9ab -2026-09-16-messages-historical-tool-input.zh.md: bcc637982420124b401ef65c668b068394b6d915 +2026-09-16-messages-historical-tool-input.md: 85e4047bb5274eb7c7adb458d564e1268d4ea81e +2026-09-16-messages-historical-tool-input.zh.md: 5a8c31dbee32094f124102ccac9ffb7b8b81b177 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md index 201b0f21ad..85e4047bb5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md @@ -10,7 +10,7 @@ Chat Completions retains tool arguments as strings, including malformed JSON fro ## Decision -The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again. +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again. This supersedes the historical argument rejection in the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md). New Messages responses still require valid object arguments before successful completion; output-limit truncation retains its existing pruning behavior. No Session event, persistence type, or protocol configuration changes. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md index bcc6379824..5a8c31dbee 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md @@ -10,7 +10,7 @@ Chat Completions 将工具参数保留为字符串,其中可能包含失败调 ## 决策 -[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。 +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。 这取代了 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的历史参数拒绝规则。新生成的 Messages 响应在成功完成前仍要求工具参数是有效对象;达到输出上限时仍按现有规则裁剪。不改变 Session 事件、持久化类型或协议配置。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml index 0898823b35..259613bc28 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md -2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54 -2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263 +2026-09-18-messages-input-history-compatibility.md: dd974fb1ae883716002a1381f4c374dee792b3b3 +2026-09-18-messages-input-history-compatibility.zh.md: 6e850328db63d5ae5cb508aa3529e9f2507182b0 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md index 1775276dee..dd974fb1ae 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md @@ -10,7 +10,7 @@ Saved subagent settlement notices can contain child reasoning and tool calls in ## Decision -The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md index 50367e33c2..6e850328db 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml new file mode 100644 index 0000000000..e2f0f66dda --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml @@ -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/bug-fix/2026-09-19-session-local-subagent-migration.md +2026-09-19-session-local-subagent-migration.md: 26911ea6e2cb31126d13e4c8c6488ff3b45563e6 +2026-09-19-session-local-subagent-migration.zh.md: 673040a3dc73d3665e42013a4704ef2d5911b23d diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md new file mode 100644 index 0000000000..26911ea6e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md @@ -0,0 +1,33 @@ +# Agent Note: Complete parent catalogs without migrating child catalogs + +Status: implemented + +English | [中文](2026-09-19-session-local-subagent-migration.zh.md) + +## Problem + +Opening a historical parent requires discovery facts from its direct children. A child's corrupt body or invalid descriptor, or an unreadable header elsewhere in the root, could reject the parent's V4 preparation and hide otherwise readable history. + +## Decision + +Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery retains its parent-catalog source. Unknown-mode rows remain browsable; `list_agents` continues to select only known continuable children. + +JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical. + +This partially supersedes child-failure propagation in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md) and [incomplete child evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md). Their format conversion, descriptor cardinality, conflict validation, and immutable publication rules remain active. + +## Alternatives considered + +**Skip catalog completion and discover children in each consumer.** This introduces incomplete tool results and additional UI states despite the parent's readable child descriptors. The parent catalog already serves these consumers. + +**Drop unreadable children from the catalog.** Published parents would lose their navigation entries. Header identity is sufficient to retain membership without inventing a mode or label. + +**Recursively prepare child catalogs.** A only needs B's own descriptor. Preparing B's descendants adds unrelated work and failure dependencies. + +**Refuse A when one child cannot be read.** The child's discovery metadata does not justify withholding the parent's healthy history. Warnings retain the child location without inventing mode or label fields. + +## Consequences + +Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. Catalog payload v0 retains its two known modes; v1 also supports `unknown`. Readers support both versions; healthy creation and complete historical facts retain v0, while unknown migration facts use v1. The V4 Session header is unchanged, and older readers reject the new payload version. + +Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md new file mode 100644 index 0000000000..673040a3dc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md @@ -0,0 +1,33 @@ +# Agent Note:补齐父目录而不迁移子目录 + +Status: implemented + +[English](2026-09-19-session-local-subagent-migration.md) | 中文 + +## 问题 + +打开历史父会话需要直属子会话的发现事实。子日志正文损坏、descriptor 无效,或根目录其他位置的 header 不可读,都可能拒绝父会话的 V4 准备,使原本可读的历史无法打开。 + +## 决策 + +打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现继续读取父 catalog。模式未知的条目仍可浏览;`list_agents` 继续只选择已知可继续的子会话。 + +JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。 + +本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)和[不完整子证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)中的子错误传播规则。其格式转换、descriptor 数量、冲突校验和不可变发布规则仍有效。 + +## 考虑过的替代方案 + +**跳过目录补齐,让每个消费者发现子项。** 父会话明明有可读的子 descriptor,却会因此产生不完整工具结果和额外 UI 状态。父 catalog 已经服务这些消费者。 + +**从目录删除不可读子会话。** 已发布父会话会丢失相应导航入口。Header 身份足以保留成员关系,无须编造模式或标签。 + +**递归准备子目录。** A 只需要 B 自身的 descriptor。准备 B 的后代会增加无关工作与失败依赖。 + +**一个子会话不可读就拒绝 A。** 子会话的发现元数据不足以成为阻断健康父历史的理由。警告保留子位置,不虚构 mode 或 label 字段。 + +## 后果 + +首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。Catalog 载荷 v0 保留两种已知模式,v1 另支持 `unknown`。读取器支持两版;正常创建及完整历史事实继续使用 v0,未知迁移事实使用 v1。V4 Session header 不变,旧读取器拒绝新载荷版本。 + +原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml index 4ede5bd457..bf6d6c2fd0 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md -2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92 -2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038 +2026-09-19-v3-incomplete-child-catalog-evidence.md: 400a84f376a7babc0a95492ca33a3bcd0761cd79 +2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 535951630bc600647724d50107e74e83ab626739 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md index 22ca915024..400a84f376 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md @@ -10,9 +10,9 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi ## Decision -The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). +The V3→V4 migration appends a complete missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions retain unknown-mode membership through [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md). Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). -Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures. +The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. JSONL isolates invalid descriptor fields and corrupt child bodies while retaining known header identity; unreadable or unsupported headers are excluded from discovery. Changed source revisions still refuse publication. ## Alternatives considered @@ -24,6 +24,6 @@ Known child creation times must still match existing parent entries. Mode/label ## Consequences -A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. +A historical child with a readable header but no usable descriptor retains unknown-mode membership in its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later mode backfill is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md index c11e6de2c2..535951630b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。 +V3→V4 迁移仅在恰好一个受支持的自身子 descriptor 提供发现字段时,追加完整的缺失父目录项。其他 descriptor 数量和不支持的版本通过[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)保留模式未知的成员关系。已有父目录项和子事件保持不变。这取代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中对缺失证据的拒绝。 -已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。 +JSONL 错误传播规则由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)部分取代;转换器仍校验已提供事实及冲突。已知子创建时间必须匹配已有父目录项。只有恰好一个受支持的自身 descriptor 时才检查 mode/label 和 descriptor 字段。JSONL 隔离无效 descriptor 字段和损坏子正文,并保留已知 header 身份;不可读或不支持的 header 不参与发现。来源修订变化仍拒绝发布。 ## Alternatives considered @@ -24,6 +24,6 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字 ## Consequences -没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。 +历史子会话具有可读 header 但没有可用 descriptor 时,在父直属子目录中保留未知模式成员关系。已有目录项仍可见。V4 后继发布后,打开它不重新扫描历史子日志;自动补齐后续模式不属于本迁移。准备过程仍在发布前重新检查子修订,因此新出现的证据不能静默绕过来源一致性检查。 单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml new file mode 100644 index 0000000000..259202f1a0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml @@ -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/bug-fix/2026-09-20-windows-embedded-mandatory-update.md +2026-09-20-windows-embedded-mandatory-update.md: 99577a4d85adc3fe13ea0ae262d5aa1af6128237 +2026-09-20-windows-embedded-mandatory-update.zh.md: abe4a9577d8e11d8de2089dc53ef07433e10bbdb diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md new file mode 100644 index 0000000000..99577a4d85 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md @@ -0,0 +1,25 @@ +# Agent Note: Windows embedded mandatory update + +Status: implemented + +English | [中文](2026-09-20-windows-embedded-mandatory-update.zh.md) + +## Problem + +A second native Windows window does not resize atomically with its parent, producing a detached overlay during maximization (#4566). Native modality also disables the parent controls that users need to exit. + +## Decision + +Windows uses one native window and a shell-origin frame below the caption. This partially supersedes the presentation decision in the [mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md); that note retains policy and installation ownership. macOS retains its native overlay. + +The isolated application preload transfers a private MessageChannel endpoint directly to the shell frame. Only messages on the paired private port authorize update IPC; synthetic window events cannot invoke actions. The main process validates the current application main frame and rebinds state delivery when the main window changes. Destroyed windows receive neither state nor focus operations. + +## Alternatives considered + +**Synchronize a second native window.** Parent movement, maximization and restoration expose independently scheduled geometry updates; removing the second window removes that synchronization problem. + +**Treat source and origin on window events as authentication.** Product scripts can construct those event fields. A private port keeps action authority outside the shared product document. + +## Consequences + +Caption menus and Web controls remain reachable. The DOM overlay blocks ordinary background input but cannot prevent product scripts from hiding or imitating presentation; it is not a tamper-proof security surface. Main-process policy and installation authorization remain separate from that display. Regression coverage requires forged-event rejection, real channel delivery, caption keyboard operation, and destroyed/replacement-window lifecycle cases. diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md new file mode 100644 index 0000000000..abe4a9577d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Windows 内嵌强更弹窗 + +Status: implemented + +[English](2026-09-20-windows-embedded-mandatory-update.md) | 中文 + +## 问题 + +Windows 的第二个原生窗口无法与父窗口原子地缩放,最大化时会出现脱离的蒙层(#4566)。原生模态还会禁用用户退出应用所需的父窗口控件。 + +## 决策 + +Windows 使用一个原生窗口和顶栏下方的壳源站 frame。此决策部分取代[强更客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)的展示决策;该说明继续负责策略与安装归属。macOS 保留原生蒙层。 + +隔离的应用预加载将私有 MessageChannel 端点直接传给壳 frame。只有配对私有端口上的消息才能授权更新 IPC;合成的 window 事件不能调用操作。主进程校验当前应用主 frame,并在主窗口变化时重新绑定状态投递。已销毁窗口不再接收状态或焦点操作。 + +## 考虑过的替代方案 + +**同步第二个原生窗口。** 父窗口移动、最大化与还原会暴露独立调度的几何更新;去掉第二个窗口即可消除这一同步问题。 + +**把 window 事件的 source 和 origin 当作身份认证。** 产品脚本可以构造这些事件字段。私有端口将操作权限保留在共享产品文档之外。 + +## 后果 + +顶栏菜单和 Web 控件仍可触达。DOM 蒙层阻止普通背景输入,但不能阻止产品脚本隐藏或仿造展示;它不是防篡改的安全界面。主进程策略和安装授权与展示相互独立。回归覆盖要求包括拒绝伪造事件、真实通道投递、顶栏键盘操作,以及窗口销毁和替换的生命周期用例。 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index df9b8b6339..4f9e640fe2 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 7431a474b55a9dae82b1c9cd36b770b31fe39602 -2026-08-20-unified-image-request-pipeline.zh.md: 247a57b8017f513c552204d951ef6931f7f9d045 +2026-08-20-unified-image-request-pipeline.md: b79eeb7d908d664362851c28bda64dab163aac89 +2026-08-20-unified-image-request-pipeline.zh.md: 444234715256c48167b0b45dbd8843a9c80079f7 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 7431a474b5..b79eeb7d90 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -34,7 +34,7 @@ Every retained request image is preceded by its display name or complete attachm ### DeepSeek Files lifecycle -The direct `deepseek-official` adapter normally uploads every retained request version through its selected protocol’s Files API and sends file-id references. [Messages](2026-09-07-deepseek-messages-adapter.md) and Chat Completions share the lifecycle while retaining their own endpoint and wire formats. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Chat records the returned `expires_at`; Messages metadata omits expiry, so its local reuse deadline uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key. +The direct `deepseek-official` adapter uploads every retained request version through the [Messages Files API](2026-09-07-deepseek-messages-adapter.md) and sends file-id references. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Messages metadata omits expiry, so local reuse uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key. An upload is indexed only after the response returns a valid native file object with a matching byte count and the client establishes the protocol’s reuse deadline. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If the model endpoint reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that model request. The affected request bytes are uploaded again and the model request is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third model request. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces a 128MiB upload limit, a 32MiB request-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 247a57b801..4442347152 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -34,7 +34,7 @@ Status: implemented ### DeepSeek Files 生命周期 -直接 `deepseek-official` 适配器通常通过所选协议的 Files API 上传每张保留的请求版本,并发送 file-id 引用。[Messages](2026-09-07-deepseek-messages-adapter.zh.md) 与 Chat Completions 共享生命周期,各自保留端点和协议格式。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会发送相同的确定性请求版本。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按 Files 端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期。Chat 记录返回的 `expires_at`;Messages 元数据不含过期时间,因此本地复用期限使用原始上传时间加配置的生存期,但不保证远端文件删除。本地映射剩余复用时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 +直连 `deepseek-official` 适配器通过 [Messages Files API](2026-09-07-deepseek-messages-adapter.zh.md) 上传每个保留的请求版本,并发送 file-id 引用。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)发送相同的确定性请求版本。上传 id 按 Files 端点、API-key 作用域及 `variantId` 建立索引。上传默认请求七天有效期。Messages 元数据不含过期时间,因此本地复用使用原始上传时间加配置的有效期,不承诺远端删除。剩余复用时间不超过一小时的映射会直接替换,不先调用 retrieve。索引从不存储 API 密钥。 只有上传响应返回有效的原生文件对象、字节数匹配,且客户端确定了该协议的复用期限时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果模型端点报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次模型请求使用的全部映射。受影响的请求字节会重新上传,模型请求只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次模型请求。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行 Files 单次上传 128MiB、请求单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index d726d38ae1..13e41b76da 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: b79c2526461843a9534d6a997029281a584e0bed -2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5 +2026-09-07-deepseek-messages-adapter.md: 29f712e57c8048ddb0c05b5f56fd4793c52bec5f +2026-09-07-deepseek-messages-adapter.zh.md: 3cf60fc2101fb835423b74a6532e5edcc5bee911 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index b79c252646..29f712e57c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -6,29 +6,29 @@ English | [中文](2026-09-07-deepseek-messages-adapter.zh.md) ## Problem -Deployments expose DeepSeek through Anthropic Messages gateways as well as chat-completions. Messages represents thinking, signatures, tool calls, tool results, and cumulative usage differently. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns. +Messages represents thinking, signatures, tool calls, tool results, and cumulative usage as native protocol fields. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns. ## Decision -The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, capability resolution, and Files lifecycle; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `messages`; shipped first-party compositions inherit that default. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration. +The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves Messages under one `deepseek-official` route and `llm-deepseek` settings namespace. The [single-protocol decision](../simplification/2026-09-19-deepseek-messages-only.md) owns the transport scope. `PreparedAdapterCall` freezes the endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration. -The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration. +The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. The adapter forwards explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration. Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; historical tool arguments use the [empty-input fallback](../bug-fix/2026-09-16-messages-historical-tool-input.md) when Messages cannot represent them. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning. -Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. +The adapter prefers Files references for deterministic request images, with upload caching, refresh, quota recovery, and attachment offload. The Files client follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md) and sends the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned roots share uploads. Metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. JSON syntax failure after a successful HTTP response does not establish a transport failure. Files decoding uses the existing `INVALID_RESPONSE` code with operation context instead of relabeling the failure as `TRANSPORT` or adding a retry; the [provider README](../../../../packages/llm/llm-deepseek/README.md) owns the error fields. System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint. -Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment. +Web displays DeepSeek with an endpoint and credential reference. Without an endpoint override, resolution uses `https://api.deepseek.com/anthropic`. Overrides must support Messages. The adapter follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog advertises the capabilities declared by its entries. -Both transports use the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. +The adapter uses the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accepts captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. ## Alternatives considered -**Separate plugins per protocol.** This duplicates credentials, catalogs, settings cards, and provider identities, and forces users to reselect models when the wire protocol changes. Protocol folders inside one plugin retain implementation isolation; Responses can add an implementation without changing the user configuration structure. +**Separate Messages provider identity.** This duplicates credentials, catalogs, and settings cards, and forces users to reselect models when adopting Messages. The `deepseek-official` identity keeps those user choices stable. **Delegate the new route to pi-ai or the Anthropic SDK.** Both provide maintained protocol implementations, but the requested direct adapter needs DeepSeek-specific configuration, attachment policy, credential resolution, and retry ownership. A small stream translator with a maintained SSE parser keeps these responsibilities explicit; the library-backed adapter remains available independently. @@ -38,6 +38,6 @@ Both transports use the existing [request-extension registry](../architecture/20 ## Consequences -The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. +The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages serves the shared base, Web, and standalone first-party compositions. Explicit endpoint overrides must support Messages. Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 49a967828e..3cf60fc210 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -6,29 +6,29 @@ Status: implemented ## 问题 -部署可以通过 Anthropic Messages 网关或 chat-completions 调用 DeepSeek。Messages 对思考、签名、工具调用、工具结果和累计用量的表示不同。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。 +Messages 以原生协议字段表示思考、签名、工具调用、工具结果和累计用量。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。 ## 决策 -[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录、能力解析和 Files 生命周期;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `messages`;随产品交付的官方组合继承该默认值。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。 +[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)在唯一的 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间下提供 Messages。[单协议决策](../simplification/2026-09-19-deepseek-messages-only.zh.md)拥有传输范围。`PreparedAdapterCall` 固定端点、凭据引用与模型能力;重试保留该代配置,后续调用读取新配置。 -适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。 +适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。适配器转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。 助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;Messages 无法表示历史工具参数时使用[空输入兜底](../bug-fix/2026-09-16-messages-historical-tool-input.zh.md)。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。 -两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。 +适配器优先使用确定性请求图片的 Files 引用,并管理上传缓存、刷新、配额恢复与附件省略。Files 客户端遵循[精确 `/v1` 根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md)并发送必需的 beta 标头。缓存 id 按解析后的 Files 根地址与凭据限定作用域,因此等价的 `/v1` 与无版本根地址共用上传。元数据不含过期时间,因此本地复用以原始上传时间为界,不断言远端删除。Files 解析失败会按独立内联图片预算重建完整请求;调用方取消会停止它。图片策略在请求和 token 计量中保留 128 MiB 保留图片预算、20 MiB 内联 base64 预算及最旧前缀省略。 成功 HTTP 响应之后的 JSON 语法错误不足以证明传输故障。Files 解码复用已有的 `INVALID_RESPONSE` code 并补充操作上下文,不将其改标为 `TRANSPORT` 或添加重试;错误字段由 [provider README](../../../../packages/llm/llm-deepseek/README.zh.md) 维护。 系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。 -Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。 +Web 显示 DeepSeek,并提供端点与凭据引用。没有端点覆盖时,解析使用 `https://api.deepseek.com/anthropic`。覆盖地址必须支持 Messages。适配器遵循精确 `/v1` 根地址规则,不从其他类似版本的后缀推断兼容性。单一模型目录公布各条目声明的能力。 -两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。 +适配器在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。 ## 考虑过的替代方案 -**每个协议独立插件。** 这会重复凭据配置、模型目录、设置卡片和 provider ID,并迫使用户在底层协议变化时重选模型。单插件中的协议目录保留实现隔离;Responses 可以增加自己的实现而不改变用户配置结构。 +**独立的 Messages 提供方身份。** 这会重复凭据、目录与设置卡,并迫使用户在采用 Messages 时重新选择模型。`deepseek-official` 身份使这些用户选择保持稳定。 **把新路由委托给 pi-ai 或 Anthropic SDK。** 两者均提供持续维护的协议实现,但所需的直接适配器需要 DeepSeek 专属配置、附件策略、凭证解析和重试所有权。小型流转换器配合持续维护的 SSE 解析器使这些职责保持明确;库实现适配器仍可独立使用。 @@ -38,6 +38,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU ## 结果 -该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 +该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 服务共享 base、Web 和独立的官方组合。显式端点覆盖必须支持 Messages。 验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml index 16d943e2a3..32ae05e725 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md -2026-09-11-desktop-mandatory-update-client.md: d66d3257360270a72d5e774282d9f7c61981929f -2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226 +2026-09-11-desktop-mandatory-update-client.md: ff55f30836a37e49229adac6414cb3a98bca4d6c +2026-09-11-desktop-mandatory-update-client.zh.md: 6c17a9fbd0ce2741b635b742be682d4c5fd9f8c6 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md index d66d325736..ff55f30836 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md @@ -14,11 +14,11 @@ The shell polls the guest protocol in the [API proposal](../../proposed/feature/ A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided. -User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows. +User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory dialog. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows. -A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available. +On macOS a shell-owned modal uses a separate sandboxed preload, and the main process verifies its exact document URL and WebContents identity. Windows embeds the shell-origin dialog in the main document below the caption so maximization and resizing have one native owner; a second native window caused visibly unsynchronized resizing (#4566). The isolated application preload owns a private MessageChannel to the frame and forwards actions to main-process IPC, which verifies the current app origin and main-frame identity. Product scripts share the DOM and can hide or imitate presentation, but cannot obtain the privileged port, clear main-process policy, or approve task teardown through window message events. The caption and its Web controls remain reachable by design. Server title/detail are text, never HTML. Esc and ordinary updater prompts cannot dismiss the block; main-process guards refuse plugin-window entry and plugin mutations while it is active. The Host keeps existing work running; task-aware installation confirmation remains separate from policy. Closing the application remains available. Windows presentation and its trust limits are owned by the [embedded-update decision](../bug-fix/2026-09-20-windows-embedded-mandatory-update.md). -The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval. +The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned IPC sender; closing, aborting, or losing an ordinary renderer cancels its approval. The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target. diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md index fd6e751a40..6c17a9fbd0 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md @@ -14,11 +14,11 @@ Status: implemented 协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。 -用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。 +用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更弹窗。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。 -壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。 +macOS 的壳模态窗口使用独立沙箱预加载,主进程校验其精确文档 URL 和 WebContents 身份。Windows 将壳源站弹窗嵌入主文档顶栏下方,让最大化与缩放只有一个原生窗口负责;第二个原生窗口曾造成可见的缩放不同步(#4566)。隔离的应用预加载拥有通向 frame 的私有 MessageChannel,将操作转发至校验当前应用源站和主 frame 身份的主进程 IPC。产品脚本共享 DOM,可以隐藏或仿造展示,但不能获得特权端口、清除主进程策略,或通过 window 消息事件批准任务收尾。顶栏及其中的 Web 控件按设计保持可触达。服务端标题/详情按文本展示,绝不作为 HTML。Esc 和常规更新弹窗不能解除阻塞;阻塞期间主进程拒绝插件窗口入口和插件修改。Host 保持现有工作运行,任务影响安装确认独立于策略。仍可退出应用。 Windows 展示及其信任限制由[内嵌强更决策](../bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md)负责。 -模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。 +模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属 IPC 发送者;关闭、中止或失去常规渲染进程会取消其批准。 恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml index 94e92c9bac..969ab3fd9e 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-persistence-type-history.md -2026-09-11-persistence-type-history.md: c7cddea5ecc479a64ad5f24f5447e869779b7f9e -2026-09-11-persistence-type-history.zh.md: 001ce197c4cd12dc64a5e2bd710e39cb2b61f23a +2026-09-11-persistence-type-history.md: 87e87041dd44c4f671409a75c1b374312736342a +2026-09-11-persistence-type-history.zh.md: cfe5c4f7bd1998d4329dc422607cfe33273d1a5a diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md index c7cddea5ec..87e87041dd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md @@ -14,7 +14,7 @@ One normalized type model supplies the readable persistence catalog, the complet The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services. -Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. +Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains unchanged and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md index 001ce197c4..cfe5c4f7bd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md @@ -14,7 +14,7 @@ Status: implemented 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。 -自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 +自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许在原样保留所有旧分支时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml new file mode 100644 index 0000000000..07287b00f8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml @@ -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/simplification/2026-09-19-deepseek-messages-only.md +2026-09-19-deepseek-messages-only.md: c10ecae950bb12f4556021de21c9716ad2906227 +2026-09-19-deepseek-messages-only.zh.md: 3cfadac6ebc0b4bb1587a8b72e9b169d2ade2ee9 diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md new file mode 100644 index 0000000000..c10ecae950 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md @@ -0,0 +1,25 @@ +# Agent Note: One Messages transport for the official DeepSeek route + +Status: implemented + +English | [中文](2026-09-19-deepseek-messages-only.zh.md) + +## Problem + +The official DeepSeek route needs Messages thinking replay, image uploads, and in-history updates. Selecting a second transport duplicates serializers, stream handling, Files wire formats, configuration branches, and fixtures without adding a required capability to this route. + +## Decision + +`dsh-llm-deepseek` uses Messages exclusively. One adapter resolves a request-local configuration snapshot and performs the request. Configuration exposes one endpoint and no protocol selector. The Files client uses Messages authentication, metadata, and pagination directly; quota cleanup scans every page before selecting the oldest owned files. + +The [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md) continues to own native thinking replay, system-message placement, image recovery, and request-extension acceptance. Replay discriminators and upload-index namespaces identify durable data and retain their existing values. The separate pi-ai adapter retains its provider-specific protocols. + +## Alternatives considered + +**Keep Chat Completions behind an optional selector.** This retains the duplicated implementation and test matrix even though the official route requires only Messages. + +**Keep a generic protocol dispatcher with one implementation.** The dispatcher adds a second dependency representation and forwards every adapter operation without selecting anything. + +## Consequences + +The official route requires a Messages-compatible endpoint. Its mock-backed recovery, Loader, CLI, and Web tests exercise Messages; removing a wire implementation does not remove those behavior checks. Committed Session generations remain readable through provider-neutral content and the retained Messages replay metadata. diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md new file mode 100644 index 0000000000..3cfadac6eb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md @@ -0,0 +1,25 @@ +# Agent Note: One Messages transport for the official DeepSeek route + +Status: implemented + +[English](2026-09-19-deepseek-messages-only.md) | 中文 + +## 问题 + +官方 DeepSeek 路由需要 Messages 思考回放、图片上传与历史内更新。选择第二种传输会重复序列化器、流处理、Files 协议格式、配置分支与夹具,却没有为这条路由增加必需能力。 + +## 决策 + +`dsh-llm-deepseek` 仅使用 Messages。一个适配器解析请求局部的配置快照并执行请求。配置暴露一个端点,不提供协议选择器。Files 客户端直接使用 Messages 认证、元数据与分页;配额清理扫描全部页面后再选择最旧的自有文件。 + +[Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)继续拥有原生思考回放、系统消息位置、图片恢复与请求扩展接受规则。回放判别值和上传索引命名空间标识持久数据,并保留现有值。独立的 pi-ai 适配器保留各提供方协议。 + +## 考虑过的替代方案 + +**在可选选择器后保留 Chat Completions。** 即使官方路由只需要 Messages,这仍会保留重复实现与测试矩阵。 + +**保留只有一个实现的通用协议分派器。** 分派器增加第二种依赖表示,并转发每个适配器操作,却没有进行任何选择。 + +## 后果 + +官方路由要求兼容 Messages 的端点。其基于 mock 的恢复、Loader、CLI 与 Web 测试使用 Messages;移除一种协议实现不会移除这些行为检查。已提交的 Session 代际继续通过提供方无关内容与保留的 Messages 回放元数据读取。 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index e50f4af442..b55464949e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md -2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8 -2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808 +2026-06-19-real-api-e2e-ci.md: bdb20b3d5413077a4ddf1c1d9293b3b808cd6c8c +2026-06-19-real-api-e2e-ci.zh.md: b95f7df62e0a57d4c58fc5c12a83905dd848c55e diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md index b218d77f08..bdb20b3d54 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -49,7 +49,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS - **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment. - **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege. -- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. +- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com/anthropic` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. - **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value or its length. ### Scope, runtime shape diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 52c988dad0..b95f7df62e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -49,7 +49,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试 - **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 - **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。 -- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。 +- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com/anthropic`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。 - **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。 ### 范围与运行时形态 diff --git a/AGENTS.md b/AGENTS.md index 7a128b6167..aa83ae8c08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) +pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build +make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options ``` ### Host sandbox failures diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..4b95df6766 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +# Short names for the Web and Desktop application commands. The package.json +# scripts they call stay the source of truth; docs/development.md documents both. +.DEFAULT_GOAL := help +.PHONY: help build web desktop dev-web dev-desktop + +PNPM ?= pnpm +ARGS ?= + +help: + @echo "make build pnpm run build complete repository build" + @echo "make web pnpm run start:web serve the built Web artifacts from source" + @echo "make desktop pnpm run start:desktop launch the built Desktop artifacts" + @echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits" + @echo "make dev-desktop pnpm run dev:desktop build, then launch Desktop" + @echo "ARGS='--no-open --port 3081' forwards options to the launched application;" + @echo "the Web commands accept dsh web flags, the Desktop launcher accepts none." + +build: + $(PNPM) run build + +web: + $(PNPM) run start:web $(ARGS) + +desktop: + $(PNPM) run start:desktop $(ARGS) + +dev-web: + $(PNPM) run dev:web $(ARGS) + +dev-desktop: + $(PNPM) run dev:desktop $(ARGS) diff --git a/README.i18n.yaml b/README.i18n.yaml index 904c7928d2..ce6f75b18d 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 36adfe913902e75ce0673adc2cb5cb692151a291 -README.zh.md: f6861b4c9490741b5e5f2d825a370766178dd9f6 +README.md: 423bccd7d9352dbc48f1705f37d35642e74f5b41 +README.zh.md: c760f87e4ac3b7dbb7bb55d733e31db5ab379836 diff --git a/README.md b/README.md index 36adfe9139..423bccd7d9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Start with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md). +`pnpm run dev:web` builds, serves, and rebuilds client bundles on source edits in one terminal, and `make help` lists the matching Make targets for Web and Desktop; the guide's application commands section owns the full table. + For agents, follow [AGENTS.md](AGENTS.md). ## Citation diff --git a/README.zh.md b/README.zh.md index f6861b4c94..c760f87e4a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -75,6 +75,8 @@ pnpm dsh web 请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。 +`pnpm run dev:web` 会在一个终端里完成构建、启动,并在源码修改时重建 client bundle;`make help` 列出 Web 与 Desktop 对应的 Make target。完整表格见开发指南的「应用命令」一节。 + 面向 agent:请遵循 [AGENTS.md](AGENTS.md)。 ## 引用 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0cff0e5cdb..cb0eddf183 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: cf0e1400f2dc8cd29cc59cefbdda379df2e0b899 -README.zh.md: a01818f59ccf54f3f826c68505bfb830a9316664 +README.md: 33465e8c96df9cbfbecc038616caf297146ec971 +README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index cf0e1400f2..33465e8c96 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -85,7 +85,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web profile -`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. +`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index a01818f59c..d25d27851c 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -87,7 +87,7 @@ dsh --profile tui ## Web Profile -`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 +`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。 ```sh dsh web diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1cd1c1636c..874e216499 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -520,7 +520,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'ACP BUILT PROFILE OK', }) const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { cwd: home, reject: false, @@ -606,7 +605,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'published headless profile reached the mock', }) const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') try { const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], { DSH_HOME: home, @@ -618,7 +616,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(result.stdout).toBe('published headless profile reached the mock') expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.') expect(server.requests.length).toBeGreaterThan(0) - expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) + expect(server.requests.every(request => request.path === '/v1/messages')).toBe(true) expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry') } finally { await server.close() @@ -743,7 +741,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'launching endpoint reached the mock', }) const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-')) writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 }) createEnvironmentProbeProfile(home, project) @@ -766,8 +763,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(result.stdout).not.toContain(apiKey) expect(result.stderr).not.toContain(apiKey) expect(server.requests).toHaveLength(1) - expect(server.requests[0]?.path).toBe('/chat/completions') - expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`) + expect(server.requests[0]?.path).toBe('/v1/messages') + expect(server.requests[0]?.headers['x-api-key']).toBe(apiKey) expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey) } finally { await server.close() diff --git a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml index e369eb59f3..aec25ed875 100644 --- a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml +++ b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml @@ -4,7 +4,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - protocol: !!js process.env.DSH_SNAPSHOT_PROTOCOL apiKeyEnv: DSH_SNAPSHOT_API_KEY baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled diff --git a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts index 16da9a81d7..59195c7669 100644 --- a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts @@ -18,9 +18,9 @@ const IMAGE_OFFLOAD_CONFIG = fileURLToPath(new URL('./fixtures/image-offload.cor const SNAPSHOTS_DIR = fileURLToPath(new URL('../../../../../../snapshots/acp/', import.meta.url)) const READ_IMAGE_WORKSPACE = fileURLToPath(new URL('../../../../../../snapshots/session/read-image/workspace/', import.meta.url)) -it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and inline fallback in assembled requests', async (protocol) => { - const filesPath = protocol === 'messages' ? '/v1/files' : '/files' - const modelPath = protocol === 'messages' ? '/v1/messages' : '/chat/completions' +it('pins Messages Files offload and inline fallback in assembled requests', async () => { + const filesPath = '/v1/files' + const modelPath = '/v1/messages' const requests: Record[] = [] const fileRequests: Array<{ method: string; path: string; bytes: number }> = [] let rejectFiles = false @@ -49,21 +49,13 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in return } const createdAt = Math.floor(Date.now() / 1_000) - response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(protocol === 'messages' ? { + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ id: 'file-api-snapshot-1', type: 'file', size_bytes: file.size, created_at: new Date(createdAt * 1_000).toISOString(), filename: 'dsh-snapshot.png', mime_type: file.type, - } : { - id: 'file-api-snapshot-1', - object: 'file', - bytes: file.size, - created_at: createdAt, - filename: 'dsh-snapshot.png', - purpose: 'user_data', - expires_at: createdAt + Number(form.get('expires_after[seconds]')), })) return } @@ -73,35 +65,18 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in } requests.push(JSON.parse(body.toString('utf8')) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - if (protocol === 'messages') { - const toolCall = requests.length === 1 - const events = [ - { type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } }, - { type: 'content_block_start', index: 0, content_block: toolCall - ? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } } - : { type: 'text', text: 'DONE' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } }, - { type: 'message_stop' }, - ] - response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')) - return - } - const events = requests.length === 1 - ? [ - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ] - : [ - 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ] - response.end(events.join('\n\n')) + const toolCall = requests.length === 1 + const events = [ + { type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: toolCall + ? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } } + : { type: 'text', text: 'DONE' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ] + response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')) + return })().catch((error: unknown) => { response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error)) }) @@ -137,7 +112,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), workspaceDir: READ_IMAGE_WORKSPACE, env: { - DSH_SNAPSHOT_PROTOCOL: protocol, DSH_SNAPSHOT_API_KEY: 'snapshot-key', DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, @@ -162,14 +136,9 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in + ' Copy to a writable path ending in .png before editing.' } const normalizedAccess = accessText(result.cwd) - const imagePrefix = protocol === 'messages' ? '' : '\n' - const fileImage = protocol === 'messages' - ? { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } - : { type: 'file', file_id: 'file-api-snapshot-1' } + const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` - const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` - .replaceAll(result.cwd, '{{cwd}}') const runtimeContext = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' @@ -179,80 +148,31 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in { type: 'text', text: 'Compare the older image ' }, { type: 'text', text: offloadedImage }, { type: 'text', text: ' with the newer image ' }, - { type: 'text', text: `${imagePrefix}${imageHandle}` }, + { type: 'text', text: imageHandle }, fileImage, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - ...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [], + { type: 'text', text: runtimeContext }, ]) - if (protocol === 'messages') { - const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> - expect(followup).toHaveLength(3) - expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([]) - expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2) - expect(followup[1]).toEqual({ role: 'assistant', content: [ - { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }, - ] }) - expect(followup[2]?.role).toBe('user') - expect(followup[2]?.content).toHaveLength(1) - const toolResult = followup[2]!.content[0]! - expect(toolResult.type).toBe('tool_result') - expect(toolResult.tool_use_id).toBe('native-read-image') - const content = toolResult.content as Array> - expect(content[0]?.type).toBe('text') - expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes') - expect(content.slice(1)).toEqual([ - { type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` }, - fileImage, - ]) - } else { - const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{ - role?: unknown - content?: unknown - }> - const toolMessage = followup.find(message => message.role === 'tool') - if (toolMessage === undefined || typeof toolMessage.content !== 'string') { - throw new Error('native read_image request has no tool content') - } - const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => ( - cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`] - )))] - let toolContent = toolMessage.content - for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}') - toolMessage.content = toolContent - expect(followup).toEqual([ - { - role: 'user', - content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`, - }, - { - role: 'user', - content: runtimeContext, - }, - { - role: 'assistant', - content: '', - tool_calls: [{ - id: 'native-read-image', - type: 'function', - function: { name: 'read_image', arguments: '{"file_path":"red.png"}' }, - }], - }, - { - role: 'tool', - tool_call_id: 'native-read-image', - content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' - + `\n${normalizedToolImageHandle}`, - }, - { - role: 'user', - content: [ - { type: 'text', text: 'Attached image(s) from tool result:' }, - { type: 'file', file_id: 'file-api-snapshot-1' }, - ], - }, - ]) - } + const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> + expect(followup).toHaveLength(3) + expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([]) + expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2) + expect(followup[1]).toEqual({ role: 'assistant', content: [ + { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }, + ] }) + expect(followup[2]?.role).toBe('user') + expect(followup[2]?.content).toHaveLength(1) + const toolResult = followup[2]!.content[0]! + expect(toolResult.type).toBe('tool_result') + expect(toolResult.tool_use_id).toBe('native-read-image') + const content = toolResult.content as Array> + expect(content[0]?.type).toBe('text') + expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes') + expect(content.slice(1)).toEqual([ + { type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` }, + fileImage, + ]) rejectFiles = true const fallback = await runScenario(input, { @@ -262,7 +182,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), workspaceDir: READ_IMAGE_WORKSPACE, env: { - DSH_SNAPSHOT_PROTOCOL: protocol, DSH_SNAPSHOT_API_KEY: 'snapshot-fallback-key', DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, @@ -280,12 +199,10 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in { type: 'text', text: 'Compare the older image ' }, { type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` }, { type: 'text', text: ' with the newer image ' }, - { type: 'text', text: `${imagePrefix}Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, - protocol === 'messages' - ? { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } } - : { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } }, + { type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - ...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [], + { type: 'text', text: runtimeContext }, ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index b8fa34b31f..bef7d1cb18 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -83,7 +83,9 @@ async function expectHeadlessStream(normalized: string, expectedPath: string): P } /** Serve one deterministic DeepSeek-compatible response while retaining its request body. */ -async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; protocol?: 'messages' } = {}): Promise { +async function deepseekDefaultsServer( + options: { waitForTitleRequest?: boolean; piAiCompatibility?: true } = {}, +): Promise { const requests: JsonObject[] = [] const paths: string[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { @@ -103,7 +105,7 @@ async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; timer = setTimeout(write, 60) return } - if (options.protocol === 'messages') { + if (options.piAiCompatibility !== true) { response.end([ { type: 'message_start', message: { id: 'defaults-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, @@ -582,7 +584,7 @@ describe('headless stream-json snapshots', () => { }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => { - const server = await deepseekDefaultsServer({ protocol: 'messages' }) + const server = await deepseekDefaultsServer() try { const result = await runLoaderSmoke({ label: 'DeepSeek adapter defaults headless stream-json snapshot', @@ -638,7 +640,7 @@ describe('headless stream-json snapshots', () => { }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('keeps the compatibility stream open until the title request arrives', async () => { - const server = await deepseekDefaultsServer({ waitForTitleRequest: true }) + const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true }) try { const response = await fetch(server.url, { method: 'POST', @@ -675,7 +677,7 @@ describe('headless stream-json snapshots', () => { }) it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => { - const server = await deepseekDefaultsServer({ waitForTitleRequest: true }) + const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true }) try { const result = await runLoaderSmoke({ label: 'pi-ai DeepSeek compatibility headless stream-json snapshot', diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts index 0e58af42f3..ecf19a7253 100644 --- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts @@ -8,7 +8,7 @@ import { withDefaultWeb, webGet } from './default-web-process.ts' const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' -it('boots the default Web profile without experimental Host modules, mounted plugins, or Client entries', async (test) => { +it('boots default Web without experimental modules or an active built-in Browser', async (test) => { await withDefaultWeb(test, async ({ url, request }) => { const auth = await webGet(url, test.signal) const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0] @@ -29,6 +29,11 @@ it('boots the default Web profile without experimental Host modules, mounted plu expect(roster.plugins.length).toBeGreaterThan(roster.entries.length) expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true) expect(roster.client.entries.length).toBeGreaterThan(0) + const browserName = '@deepseek-ai/dsh-client-ui-sidebar-browser' + const browserEntry = roster.entries.find(entry => entry.name === browserName) + expect(browserEntry).toBeDefined() + expect(browserEntry!.state).toBeUndefined() + expect(delivered.entries.some(entry => entry.id === browserName)).toBe(false) expect(experimentalRuntimeReferences(roster)).toEqual([]) const contaminated = await request('mount-experimental') diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index bea2649956..ad60f636ee 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2b41f6deeda70db186fb38ee6b5590bf8c459835 -README.zh.md: 24128293d5a246e33d36ba1ecbc10ae3cf594ea5 +README.md: aa2d9163067ea48b398e200b644fbeda6ea370a9 +README.zh.md: a27b551b4fc2ea37d5a2da45edbcf4b2901e5dde diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b41f6deed..aa2d916306 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -93,7 +93,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. +The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. ## Package @@ -322,7 +322,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. -A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. +A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 24128293d5..a27b551b4f 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -54,7 +54,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 -Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 +Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。 macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 @@ -93,7 +93,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 +Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 ## 打包 @@ -322,7 +322,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 -扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 diff --git a/apps/desktop/renderer/mandatory-update-frame.js b/apps/desktop/renderer/mandatory-update-frame.js new file mode 100644 index 0000000000..098059e237 --- /dev/null +++ b/apps/desktop/renderer/mandatory-update-frame.js @@ -0,0 +1,42 @@ +/** The embedded shell page delegates actions only to its owning application preload. */ +if (window.parent !== window && window.dshMandatoryUpdate === undefined) { + let state + let port + let sequence = 0 + const listeners = new Set() + const requests = new Map() + const initial = Promise.withResolvers() + const receive = event => { + const message = event.data + if (message?.type === 'dsh-mandatory-state' && message.state) { + state = message.state + initial.resolve(state) + for (const listener of listeners) listener(state) + } + if (message?.type === 'dsh-mandatory-result') { + const request = requests.get(message.id) + if (!request) return + requests.delete(message.id) + if (message.ok) request.resolve() + else request.reject(new Error('Update action failed')) + } + } + window.addEventListener('message', event => { + if (!event.isTrusted || event.source !== window.parent || event.origin !== 'dsh-app://app' + || event.data?.type !== 'dsh-mandatory-connect' || event.ports.length !== 1 || port) return + port = event.ports[0] + port.onmessage = receive + }) + window.addEventListener('pagehide', () => { port?.close() }, { once: true }) + window.dshMandatoryUpdate = { + status: () => state ? Promise.resolve(state) : initial.promise, + subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) }, + action: (action, version, revision) => { + const id = ++sequence + const request = Promise.withResolvers() + requests.set(id, request) + port.postMessage({ type: 'dsh-mandatory-action', id, action, version, revision }) + return request.promise + }, + } +} diff --git a/apps/desktop/renderer/mandatory-update.html b/apps/desktop/renderer/mandatory-update.html index 673c5d0953..f009ab8a37 100644 --- a/apps/desktop/renderer/mandatory-update.html +++ b/apps/desktop/renderer/mandatory-update.html @@ -32,6 +32,7 @@ + diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index b768de5544..245007a7ab 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -43,6 +43,11 @@ try { for (const owner of [application, project]) { await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir') } + const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}` + const targetRoot = join(application, '.desktop-build/targets', target) + await mkdir(targetRoot, { recursive: true }) + await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'), + process.platform === 'win32' ? 'junction' : 'dir') await writeFile(join(profile, 'cordis.patch.yml'), JSON.stringify([ { id: 'webserver', config: { host: '127.0.0.1', port: 0 } }, { id: 'llm-deepseek', disabled: true }, { id: 'session-title-llm', disabled: true }, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 63680a5c0b..7531a7cfa9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -410,7 +410,7 @@ async function main(): Promise { protocol.handle(SCHEME, (request) => { const url = new URL(request.url) // Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets. - // Removing this route leaves those modal windows empty while their parent remains blocked. + // Removing this route leaves those shell windows and embedded documents empty while their parent remains blocked. if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer')) if (url.hostname === 'app') { if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/') diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index 17438ab15e..cfca36679b 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -2,10 +2,10 @@ import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import type { DesktopLocale } from './locale.ts' -import type { DesktopUpdateState } from './ipc.ts' +import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts' import { MANDATORY_IPC } from './mandatory-update-ipc.ts' -import { createMandatoryUpdateWindow } from './update-overlay.ts' +import { createUpdateOverlay } from './update-overlay.ts' import { DesktopUpdateAttention } from './update-attention.ts' /** A renderer action never carries a URL or authorizes a different version. */ @@ -46,8 +46,12 @@ export interface MandatoryUpdateWindowOptions { // main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. const page = 'dsh-app://shell/mandatory-update.html' -/** A modal child blocks the product window without cancelling work in the Host. */ +/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */ export class DesktopMandatoryUpdateWindow { + private readonly embedded = process.platform === 'win32' + private embeddedParent: BrowserWindow | undefined + private embeddedBlocking = false + private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() } private window: BrowserWindow | undefined private closing: ReturnType | undefined private disposed = false @@ -65,6 +69,8 @@ export class DesktopMandatoryUpdateWindow { /** @param options - Main-process actions and immutable deployment/navigation settings. */ constructor(private readonly options: MandatoryUpdateWindowOptions) { this.attention = new DesktopUpdateAttention(options.locale) + this.embeddedParent = this.embedded ? options.parent() : undefined + this.embeddedParent?.webContents.on('did-finish-load', this.publishEmbedded) ipcMain.handle(MANDATORY_IPC.status, (event) => { this.assertSender(event); return this.view() }) ipcMain.handle(MANDATORY_IPC.action, (event, action: unknown, version: unknown, confirmationRevision: unknown) => { this.assertSender(event) @@ -103,7 +109,7 @@ export class DesktopMandatoryUpdateWindow { } /** Active modal used as the owner of shell installation-confirmation dialogs. */ - get confirmationWindow(): BrowserWindow | undefined { return this.window } + get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.window } /** * @param version - Updater-owned target, already downloaded and verified. @@ -119,8 +125,9 @@ export class DesktopMandatoryUpdateWindow { this.confirmation = { version, active, revision: ++this.confirmationRevision, resolve } this.sync() const parent = this.options.parent() - if (parent !== undefined && this.window !== undefined) { - this.attention.ready(version, parent, this.window, () => { + const owner = this.confirmationWindow + if (parent !== undefined && owner !== undefined) { + this.attention.ready(version, parent, owner, () => { if (!this.disposed && this.options.policy().blocking && this.confirmation !== undefined) this.focus() }) } else this.finishConfirmation(false) @@ -137,6 +144,18 @@ export class DesktopMandatoryUpdateWindow { /** Publish current status, create the block immediately, or close it only after policy clearance. */ sync(): void { if (this.disposed) return + if (this.embedded) { + const parent = this.options.parent() + if (parent !== this.embeddedParent) { + if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) { + this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded) + } + this.embeddedBlocking = false + this.embeddedParent = parent + if (parent !== undefined && !parent.isDestroyed()) parent.webContents.on('did-finish-load', this.publishEmbedded) + } + if (parent === undefined || parent.isDestroyed()) return + } if (!this.options.policy().blocking) { this.finishConfirmation(false) this.attention.reset() @@ -153,16 +172,25 @@ export class DesktopMandatoryUpdateWindow { }, 150) } this.error = undefined + if (this.embedded && this.embeddedBlocking) { + this.embeddedBlocking = false + this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view()) + } return } clearTimeout(this.closing) this.closing = undefined if (this.navigationUrl !== this.options.policy().page) this.clearNavigation() if (this.options.update().phase === 'error') this.restart = undefined + if (this.embedded) { + this.embeddedBlocking = true + this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view()) + return + } if (this.window === undefined) { const parent = this.options.parent() if (parent === undefined) return - const window = createMandatoryUpdateWindow(parent, this.options.preload, this.options.locale.messages.mandatoryTitle) + const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false) this.window = window window.setMenu(null) window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } }) @@ -187,13 +215,15 @@ export class DesktopMandatoryUpdateWindow { focus(): void { this.sync() const parent = this.options.parent() + if (parent?.isDestroyed()) return if (parent?.isMinimized()) parent.restore() parent?.show() + if (this.embedded) parent?.focus() this.window?.show() this.window?.focus() } - /** Detach IPC and release the modal during application shutdown. */ + /** Detach IPC and release the modal during shutdown, including after the main window closes. */ dispose(): void { this.disposed = true clearTimeout(this.closing) @@ -203,6 +233,10 @@ export class DesktopMandatoryUpdateWindow { this.clearNavigation() ipcMain.removeHandler(MANDATORY_IPC.status) ipcMain.removeHandler(MANDATORY_IPC.action) + if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) { + this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded) + this.embeddedParent.webContents.send(MANDATORY_IPC.state, { ...this.view(), policy: { blocking: false, checking: false } }) + } this.window?.destroy() this.window = undefined } @@ -258,6 +292,15 @@ export class DesktopMandatoryUpdateWindow { } private assertSender(event: IpcMainInvokeEvent): void { + if (this.embedded) { + const parent = this.options.parent() + if (parent === undefined || parent.isDestroyed() || event.sender !== parent.webContents + || event.senderFrame !== parent.webContents.mainFrame) { + throw new Error('desktop policy: rejected unowned renderer') + } + assertDesktopSender(event, ['app']) + return + } if (event.sender !== this.window?.webContents || event.senderFrame !== this.window.webContents.mainFrame || event.senderFrame.url !== page) throw new Error('desktop policy: rejected unowned renderer') } diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 10ea92326f..3baf8dd2b9 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -5,6 +5,7 @@ import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePrese import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' import { syncWindowsAppearance } from './preload-windows.ts' +import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts' const product: DshDesktopProductApi = { protocolVersion: 1, @@ -21,6 +22,7 @@ const product: DshDesktopProductApi = { if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { syncWindowsAppearance() + if (process.platform === 'win32') installMandatoryUpdateOverlay() contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, }) diff --git a/apps/desktop/src/preload-mandatory-overlay.ts b/apps/desktop/src/preload-mandatory-overlay.ts new file mode 100644 index 0000000000..6c4076e091 --- /dev/null +++ b/apps/desktop/src/preload-mandatory-overlay.ts @@ -0,0 +1,85 @@ +/** Mounts the shell-owned Windows update document inside the main window's content area. */ +import { ipcRenderer } from 'electron' +import { MANDATORY_IPC } from './mandatory-update-ipc.ts' +import type { MandatoryUpdateView } from './mandatory-update-window.ts' +import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' + +/** Keep update actions on a private channel to the shell frame; the shared product DOM is not a tamper-proof display. */ +export function installMandatoryUpdateOverlay(): void { + let disposed = false + let state: MandatoryUpdateView | undefined + let host: HTMLDivElement | undefined + let frame: HTMLIFrameElement | undefined + let port: MessagePort | undefined + let closing: ReturnType | undefined + const publish = (): void => { port?.postMessage({ type: 'dsh-mandatory-state', state }) } + const remove = (): void => { port?.close(); port = undefined; host?.remove(); host = undefined; frame = undefined } + const render = (): void => { + if (state === undefined || document.readyState === 'loading') return + if (!state.policy.blocking) { + if (frame === undefined || closing !== undefined) return + publish() + closing = setTimeout(remove, 150) + return + } + clearTimeout(closing) + closing = undefined + if (frame === undefined) { + host = document.createElement('div') + host.style.cssText = `position:fixed;top:${WINDOWS_TITLEBAR_HEIGHT}px;left:0;right:0;bottom:0;z-index:2147483647;backdrop-filter:blur(2px)` + const shadow = host.attachShadow({ mode: 'closed' }) + frame = document.createElement('iframe') + frame.title = state.locale.messages.mandatoryTitle + frame.style.cssText = 'display:block;width:100%;height:100%;border:0;background:transparent' + frame.src = 'dsh-app://shell/mandatory-update.html' + shadow.append(frame) + document.documentElement.append(host) + frame.addEventListener('load', () => { + port?.close() + const channel = new MessageChannel() + port = channel.port1 + port.onmessage = message + frame?.contentWindow?.postMessage({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2]) + publish() + frame?.focus() + }) + } + publish() + } + const receive = (_event: Electron.IpcRendererEvent, next: MandatoryUpdateView): void => { state = next; render() } + const message = (event: MessageEvent): void => { + const value = event.data + if (typeof value !== 'object' || value === null || !('type' in value)) return + if (value.type !== 'dsh-mandatory-action' || !('id' in value) || !Number.isSafeInteger(value.id) + || !('action' in value) || !('version' in value) || !('revision' in value)) return + const target = port + if (target === undefined) return + void ipcRenderer.invoke(MANDATORY_IPC.action, value.action, value.version, value.revision).then( + () => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: true }) }, + () => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: false }) }, + ) + } + const blockBackgroundKey = (event: KeyboardEvent): void => { + if (frame === undefined || state?.policy.blocking !== true + || event.composedPath().some(target => target instanceof HTMLElement && target.hasAttribute('data-windows-menu'))) return + event.preventDefault() + event.stopImmediatePropagation() + frame.focus() + } + ipcRenderer.on(MANDATORY_IPC.state, receive) + void ipcRenderer.invoke(MANDATORY_IPC.status).then((initial: MandatoryUpdateView) => { + if (!disposed && state === undefined) { state = initial; render() } + }, () => { + // Development may omit policy configuration; a later main-frame load still resynchronizes configured policy. + }) + window.addEventListener('keydown', blockBackgroundKey, true) + window.addEventListener('DOMContentLoaded', render, { once: true }) + window.addEventListener('pagehide', () => { + disposed = true + clearTimeout(closing) + remove() + ipcRenderer.off(MANDATORY_IPC.state, receive) + window.removeEventListener('keydown', blockBackgroundKey, true) + window.removeEventListener('DOMContentLoaded', render) + }, { once: true }) +} diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index 2ff0b8c0d4..a6e341ecb9 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -47,19 +47,3 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) return window } - -/** A native Windows modal retains its own title bar while the product window remains blocked. */ -export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string, - platform: NodeJS.Platform = process.platform): BrowserWindow { - if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false) - const window = new BrowserWindow({ - parent, modal: true, show: false, title, - width: 640, height: 560, minWidth: 480, minHeight: 360, - movable: true, resizable: true, maximizable: true, - backgroundColor: '#f5f5f5', - webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, - }) - window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) - window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) - return window -} diff --git a/apps/desktop/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml index 791271a555..6dd6065d3b 100644 --- a/apps/desktop/tests/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/tests/README.md -README.md: 6171b058adad8e34a3275c013bd875e00f586afc -README.zh.md: fa18cd947e1315248b5449eaf9bc426bba3d18fc +README.md: a2684a2bced4326bdc316ff56d80003061d0136d +README.zh.md: fba5b65aa41aa14b9971f80676c8f0fef4d7be9e diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md index 6171b058ad..a2684a2bce 100644 --- a/apps/desktop/tests/README.md +++ b/apps/desktop/tests/README.md @@ -33,7 +33,7 @@ The local command builds Desktop and executes Electron 44 with the actual HTTP u | Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass | | Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass | | Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup | -| Actual mandatory window | Text-only server content, native Windows move/resize/maximize and restore, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy | +| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy | | Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass | | Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload | | Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking | @@ -47,7 +47,7 @@ Chromium headless shell revision 1228 is installed in the ignored `.desktop-buil The [built Host scenario](fixtures/host-update-qualification.mjs) uses the actual profile Loader, standard agent preset, task services, and Node background processes. It verifies queued turns/steps, a running model request, pending questions/approvals, global and agent jobs in running/stopping states, admission locking without cancellation, restored admission, and rejected inspection after Host disposal. Only model responses and human answers are substituted. Two independent invocations pass concurrently with private profiles and session data; all owned agents, jobs, and Hosts finish before a success report is written. -The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results. +The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results. The [Windows signature runner](../scripts/test-windows-update-signature.mjs) uses the installed electron-builder metadata generator and `NsisUpdater` verifier with the public release certificate and real executable inputs. Matching publisher attributes pass; the same valid signature with a different expected publisher and an unsigned executable are rejected. A missing-publisher negative control confirms that verification is skipped. Unit regressions cover DN escaping, incomplete identities, and explicit or host-default Windows targets; removing the publisher configuration fails both metadata cases. This check does not download, install, or sign an artifact. diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md index fa18cd947e..fba5b65aa4 100644 --- a/apps/desktop/tests/README.zh.md +++ b/apps/desktop/tests/README.zh.md @@ -33,7 +33,7 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录 | 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 | | 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 | | 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 | -| 真实强更窗口 | 服务端纯文本内容、Windows 原生窗口移动/缩放/最大化及还原、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 | +| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 | | 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 | | 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 | | 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 | @@ -47,7 +47,7 @@ Chromium headless shell revision 1228 已安装在忽略目录 `.desktop-build/p [构建后 Host 场景](fixtures/host-update-qualification.mjs) 使用真实 profile Loader、standard agent 预设、任务服务和 Node 后台进程。它验证排队的轮次/步骤、运行中的模型请求、等待答复的提问/审批、运行/停止中的全局和 agent job、不会取消任务的准入锁定、准入恢复,以及 Host 释放后的检查拒绝。仅模型响应和人工答复使用替身。两个独立调用使用私有 profile 和会话数据并发通过;全部自有 agent、job 和 Host 完成后才写入成功报告。 -[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件操作页面按钮。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。 +[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。 [Windows 验签运行器](../scripts/test-windows-update-signature.mjs) 使用已安装的 electron-builder 元数据生成器和 `NsisUpdater` 验签器,输入为公开发布证书和真实可执行文件。发布者属性匹配时通过;同一有效签名在预期发布者不同时被拒绝,未签名可执行文件也被拒绝。缺少发布者的负对照确认验签被跳过。单元回归覆盖 DN 转义、不完整身份,以及显式或宿主默认 Windows 目标;移除发布者配置会使两条元数据用例失败。此检查不下载、安装或签名产物。 diff --git a/apps/desktop/tests/fixtures/local-updater.mjs b/apps/desktop/tests/fixtures/local-updater.mjs index 060000fc35..a88c08b881 100644 --- a/apps/desktop/tests/fixtures/local-updater.mjs +++ b/apps/desktop/tests/fixtures/local-updater.mjs @@ -1,5 +1,8 @@ /** Actual Electron HTTP/download qualification; installer execution is forbidden. */ +import { mandatoryFrame } from './mandatory-frame.mjs' +import { setTimeout as delay } from 'node:timers/promises' import assert from 'node:assert/strict' +import { once } from 'node:events' import fs from 'node:fs' import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { join, sep } from 'node:path' @@ -225,13 +228,14 @@ async function main() { }) await scenario('mandatory-real-window-policy-download-retry-and-clear', async () => { protocol.handle('dsh-app', async request => { + if (new URL(request.url).hostname === 'app') return new Response('Updater qualification', { headers: { 'content-type': 'text/html' } }) const path = new URL(request.url).pathname.slice(1) - assert.ok(['mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path)) + assert.ok(['mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path)) const mime = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html' return new Response(await readFile(new URL(`../../renderer/${path}`, import.meta.url)), { headers: { 'content-type': mime } }) }) - const parent = new BrowserWindow({ show: true, width: 900, height: 650 }) - await parent.loadURL('data:text/html;charset=utf-8,Local updater qualification') + const parent = new BrowserWindow({ show: true, width: 900, height: 650, webPreferences: { preload: fileURLToPath(new URL('../../lib/preload-app.cjs', import.meta.url)), sandbox: true, contextIsolation: true } }) + await parent.loadURL('dsh-app://app/') const f = await fixture('hold-download') const config = resolveDesktopPolicyConfig({ origin: server.url, allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 600_000, timeoutMs: 1000 }, true) @@ -262,51 +266,46 @@ async function main() { server.policy('force') await policy.check('launch') const window = modal.confirmationWindow - assert.ok(window?.isModal()) + assert.ok(window) + const contents = process.platform === 'win32' ? await mandatoryFrame(parent) : window.webContents + assert.equal(window.isModal(), false) + assert.equal(parent.isEnabled(), true) window.webContents.on('console-message', event => { console.log('mandatory renderer:', event.message) }) window.webContents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) }) async function until(expression) { - try { return await window.webContents.executeJavaScript(`new Promise((resolve, reject) => { + try { return await contents.executeJavaScript(`new Promise((resolve, reject) => { const observer = new MutationObserver(check); const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Modal DOM condition timed out')); }, 10000); function check() { if (${expression}) { observer.disconnect(); clearTimeout(deadline); resolve(true); } } observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true }); check(); })`) } catch (error) { - console.error('Modal condition:', expression, await window.webContents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })')) + console.error('Modal condition:', expression, await contents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })')) throw error } } await until("document.getElementById('title')?.textContent === '需要更新'") - assert.equal(await window.webContents.executeJavaScript("document.querySelector('#title b') === null"), true) + assert.equal(await contents.executeJavaScript("document.querySelector('#title b') === null"), true) if (process.platform === 'win32') { - assert.equal(window.isMovable(), true) - assert.equal(window.isResizable(), true) - assert.equal(window.isMaximizable(), true) - const bounds = window.getBounds() - window.setPosition(bounds.x + 20, bounds.y + 20) - assert.notDeepEqual(window.getBounds(), bounds) - window.maximize() - assert.equal(window.isMaximized(), true) - window.unmaximize() - assert.equal(window.isMaximized(), false) + assert.equal(window, parent) + assert.equal(parent.getChildWindows().length, 0) } - assert.equal(await window.webContents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false) + assert.equal(await contents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false) assert.equal(window.isDestroyed(), false) assert.equal(f.installations.length, 0) - await window.webContents.executeJavaScript("document.getElementById('page').click()") + await contents.executeJavaScript("document.getElementById('page').click()") await until("document.getElementById('browser-message').textContent.includes('无法打开浏览器')") assert.deepEqual(opened, ['https://downloads.example.com/desktop']) - await window.webContents.executeJavaScript("document.getElementById('copy').click()") + await contents.executeJavaScript("document.getElementById('copy').click()") await copyComplete.promise await until("document.getElementById('copy').textContent === '已复制链接'") assert.deepEqual(copied, opened) await f.coordinator.check() modal.sync() await until("!document.getElementById('update').hidden") - await window.webContents.executeJavaScript("document.getElementById('update').click()") + await contents.executeJavaScript("document.getElementById('update').click()") await server.arrived() assert.equal(f.coordinator.state.phase, 'downloading') - assert.equal(await window.webContents.executeJavaScript("document.getElementById('page').hidden"), true) + assert.equal(await contents.executeJavaScript("document.getElementById('page').hidden"), true) server.release() await until("document.getElementById('update').textContent === '停止任务并更新' && !document.getElementById('update').disabled") assert.equal(f.installations.length, 0) @@ -314,14 +313,14 @@ async function main() { server.policy('stall') await policy.check('manual', true) assert.equal(window.isDestroyed(), false) - assert.equal(await window.webContents.executeJavaScript("document.getElementById('error').hidden"), true) - const snapshot = await window.webContents.executeJavaScript(`({ + assert.equal(await contents.executeJavaScript("document.getElementById('error').hidden"), true) + const snapshot = await contents.executeJavaScript(`({ title: document.getElementById('title').textContent, detail: document.getElementById('detail').textContent, status: document.getElementById('status').textContent, version: document.getElementById('version').textContent, buttons: [...document.querySelectorAll('button')].filter(button => button.getClientRects().length > 0).map(button => button.textContent), })`) assert.deepEqual(snapshot, JSON.parse(await readFile(new URL('../expected/mandatory-update-zh.json', import.meta.url), 'utf8'))) - await window.webContents.executeJavaScript("document.getElementById('later').click()") + await contents.executeJavaScript("document.getElementById('later').click()") await until("document.getElementById('update').textContent === '继续安装更新' && !document.getElementById('update').disabled") assert.equal(f.installations.length, 0) let capture @@ -333,19 +332,19 @@ async function main() { } const restartRequested = Promise.withResolvers() f.restart(async () => { restartRequested.resolve(); return false }) - await window.webContents.executeJavaScript("document.getElementById('update').click()") + await contents.executeJavaScript("document.getElementById('update').click()") await restartRequested.promise await until("!document.getElementById('update').disabled") assert.equal(f.installations.length, 0) assert.equal(policy.state.blocking, true) f.restart(async () => { throw new DesktopUpdatePreparationError('stop-failed', resolveDesktopLocale('zh-CN').messages.updateStopFailed, 'exit 0; shutdown acknowledged false') }) - await window.webContents.executeJavaScript("document.getElementById('update').click()") + await contents.executeJavaScript("document.getElementById('update').click()") await until("!document.getElementById('technical-details').hidden && !document.getElementById('update').disabled") - assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details').open"), false) + assert.equal(await contents.executeJavaScript("document.getElementById('technical-details').open"), false) for (const expanded of [false, true]) { if (expanded) { - assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true) + assert.equal(await contents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true) } const name = expanded ? 'mandatory-error-expanded.png' : 'mandatory-error-collapsed.png' await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG()) @@ -354,14 +353,20 @@ async function main() { assert.equal(policy.state.blocking, true) assert.equal(f.installations.length, 0) server.policy('clear') + const closed = process.platform === 'win32' ? undefined : once(window, 'closed') await policy.check('manual', true) - assert.equal(window.isDestroyed(), true) + if (closed) { await closed; assert.equal(window.isDestroyed(), true) } + else { + const deadline = Date.now() + 10000 + while (!contents.detached && Date.now() < deadline) await delay(20) + assert.equal(contents.detached, true) + } assert.equal(parent.isDestroyed(), false) } finally { server.release() + parent.destroy() modal.dispose() await policy.dispose() - parent.destroy() shell.openExternal = originalOpen clipboard.writeText = originalCopy clipboard.readText = originalRead diff --git a/apps/desktop/tests/fixtures/mandatory-frame.mjs b/apps/desktop/tests/fixtures/mandatory-frame.mjs new file mode 100644 index 0000000000..797d3373f7 --- /dev/null +++ b/apps/desktop/tests/fixtures/mandatory-frame.mjs @@ -0,0 +1,35 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js' +import { setTimeout as delay } from 'node:timers/promises' + +export async function mandatoryFrame(parent) { + const deadline = Date.now() + 10000 + while (Date.now() < deadline) { + const frame = parent.webContents.mainFrame.frames.find(frame => frame.url === 'dsh-app://shell/mandatory-update.html') + if (frame && await frame.executeJavaScript("document.readyState === 'complete'")) return frame + await delay(20) + } + throw new Error('Mandatory update frame did not load') +} + +export async function mandatoryFrameDriver(parent) { + const frame = await mandatoryFrame(parent) + if (!parent.webContents.debugger.isAttached()) parent.webContents.debugger.attach('1.3') + const contents = new Proxy(parent.webContents, { get(target, key) { + if (key === 'executeJavaScript') return frame.executeJavaScript.bind(frame) + if (key === 'sendInputEvent') return event => { + if (typeof event.y !== 'number') return target.sendInputEvent(event) + return target.debugger.sendCommand('Input.dispatchMouseEvent', { + type: event.type === 'mouseDown' ? 'mousePressed' : event.type === 'mouseUp' ? 'mouseReleased' : 'mouseMoved', + x: event.x, y: event.y + WINDOWS_TITLEBAR_HEIGHT, button: event.button ?? 'none', clickCount: event.clickCount ?? 0, + }) + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } }) + return new Proxy(parent, { get(target, key) { + if (key === 'webContents') return contents + if (key === 'isDestroyed') return () => frame.detached + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } }) +} diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs index f6302750d9..51e930cb2f 100644 --- a/apps/desktop/tests/fixtures/workspace-updates.mjs +++ b/apps/desktop/tests/fixtures/workspace-updates.mjs @@ -1,4 +1,6 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js' /** Real Electron main entry, preload, shared Web Host, and local updater; no installer executes. */ +import { mandatoryFrameDriver } from './mandatory-frame.mjs' import assert from 'node:assert/strict' import { registerHooks } from 'node:module' import { appendFile, readFile, writeFile } from 'node:fs/promises' @@ -95,6 +97,9 @@ async function documentReady(window, expression) { })`)) } async function windowAt(url) { + if (process.platform === 'win32' && url === 'dsh-app://shell/mandatory-update.html') { + return mandatoryFrameDriver(await windowAt('dsh-app://app/')) + } const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url) if (existing) return existing return new Promise((resolve, reject) => { @@ -146,13 +151,15 @@ async function press(window, expression) { if (!target.contains(document.elementFromPoint(point.x, point.y))) throw new Error('Click target is obscured'); return point; })()`)) - window.webContents.sendInputEvent({ type: 'mouseMove', ...point }) - window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }) - window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }) + await window.webContents.sendInputEvent({ type: 'mouseMove', ...point }) + await window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }) + await window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }) } async function clickText(window, label) { + const title = await window.webContents.executeJavaScript("document.getElementById('title').textContent") await press(window, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === ${JSON.stringify(label)})`) - await waitFor(() => window.isDestroyed(), `dialog response: ${label}`) + await waitFor(async () => window.isDestroyed() + || await window.webContents.executeJavaScript("document.getElementById('title').textContent") !== title, `dialog response: ${label}`) } async function dialogWith(message) { let found @@ -173,9 +180,9 @@ async function qualify() { try { await import(entry) console.log('workspace qualification: compiled main module loaded') - const applicationUrl = await fixture.ready.promise + await fixture.ready.promise console.log('workspace qualification: Host process ready') - mainWindow = await windowAt(new URL('/', applicationUrl).href) + mainWindow = await windowAt('dsh-app://app/') await documentReady(mainWindow, `document.querySelector('[class*="frame"]') && window.dshDesktop?.updates`) assert.equal(mainWindow.isVisible(), true, 'Workspace qualification requires a visible application window') console.log('workspace qualification: workspace document ready') @@ -187,7 +194,15 @@ async function qualify() { console.log('workspace qualification: startup API requests settled') const messages = resolveDesktopLocale(app.getLocale()).messages await screenshot(mainWindow, 'workspace.png') - const menu = Menu.getApplicationMenu().items[0].submenu.items + let menu + if (process.platform === 'win32') { + const popup = Menu.prototype.popup + try { + Menu.prototype.popup = function (options) { menu = this.items; options.callback?.() } + await mainWindow.webContents.executeJavaScript("document.querySelector('[data-windows-menu]').shadowRoot.querySelector('button').click()") + await waitFor(() => menu !== undefined, 'Windows application menu') + } finally { Menu.prototype.popup = popup } + } else menu = Menu.getApplicationMenu().items[0].submenu.items const checkMenu = menu.find(item => item.label === messages.checkUpdatesMenu) assert.ok(checkMenu) if (interactive) { @@ -203,9 +218,8 @@ async function qualify() { const checking = await dialogWith(messages.updateChecking) await screenshot(checking, 'checking.png') server.release() - await waitFor(() => checking.isDestroyed(), 'checking dialog to close') - console.log('workspace qualification: checking dialog closed') const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion())) + assert.equal(current, checking) await clickText(current, messages.updateAcknowledge) assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0) cases.push('native-menu-checking-and-current-without-download') @@ -272,16 +286,21 @@ async function qualify() { await documentReady(mandatory, `document.getElementById('title')?.textContent === '需要更新'`) console.log('workspace qualification: mandatory title rendered') assert.equal(await mandatory.webContents.executeJavaScript(`document.getElementById('title').children.length`), 0) - assert.equal(mandatory.isMovable(), true) - assert.equal(mandatory.isResizable(), true) - assert.equal(mandatory.isMaximizable(), true) - const originalBounds = mandatory.getBounds() - mandatory.setPosition(originalBounds.x + 20, originalBounds.y + 20) - assert.notDeepEqual(mandatory.getBounds(), originalBounds) - mandatory.maximize() - await waitFor(() => mandatory.isMaximized(), 'mandatory maximize') - mandatory.unmaximize() - await waitFor(() => !mandatory.isMaximized(), 'mandatory restore') + assert.equal(mandatory.isModal(), false) + await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window') + assert.equal(mainWindow.isEnabled(), true) + if (process.platform === 'win32') assert.equal(mainWindow.getChildWindows().length, 0) + const originalBounds = mainWindow.getBounds() + mainWindow.setPosition(originalBounds.x + 20, originalBounds.y + 20) + mainWindow.maximize() + await waitFor(() => mainWindow.isMaximized(), 'blocked parent maximize') + mainWindow.unmaximize() + await waitFor(() => !mainWindow.isMaximized(), 'blocked parent restore') + if (process.platform === 'win32') { + const viewport = await mandatory.webContents.executeJavaScript('({ width: innerWidth, height: innerHeight })') + const bounds = mainWindow.getContentBounds() + assert.deepEqual(viewport, { width: bounds.width, height: bounds.height - WINDOWS_TITLEBAR_HEIGHT }) + } mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) assert.equal((await control('status')).queued, 1) @@ -329,7 +348,8 @@ async function qualify() { server.policy('force') checkMenu.click() - const forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html') + let forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html') + await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window for recovery') await press(forcedRecovery, `document.getElementById('update')`) await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.installAndRestart)}`) assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false) @@ -340,7 +360,11 @@ async function qualify() { await documentReady(forcedRecovery, `document.getElementById('error')?.textContent === ${JSON.stringify(messages.updateStopFailed)}`) await screenshot(forcedRecovery, 'mandatory-stop-failure.png') await waitFor(() => fixture.host !== forcedHost && fixture.readyHosts.has(fixture.host), 'mandatory replacement Host readiness') - assert.equal(mainWindow.isEnabled(), false) + if (process.platform === 'win32') { + await waitFor(() => forcedRecovery.isDestroyed(), 'recovery reload replaces the embedded document') + forcedRecovery = await mandatoryFrameDriver(mainWindow) + } + assert.equal(mainWindow.isEnabled(), true) assert.equal(fixture.installations.length, 0) await control('queue') await press(forcedRecovery, `document.getElementById('update')`) @@ -349,7 +373,7 @@ async function qualify() { await press(forcedRecovery, `document.getElementById('later')`) await waitFor(() => fixture.coordinator.state.phase === 'ready', 'mandatory deferred retry readiness') assert.equal(forcedRecovery.isDestroyed(), false) - assert.equal(mainWindow.isEnabled(), false) + assert.equal(mainWindow.isEnabled(), true) assert.equal(fixture.installations.length, 0) server.policy('clear') checkMenu.click() diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 3c792be2eb..8547bc28fe 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -62,7 +62,9 @@ const harness = await vi.hoisted(async () => { getZoomFactor: () => 1, focus: vi.fn(), sendInputEvent: vi.fn(), - send: vi.fn(), + send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => { + if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve() + }), }) readonly show = vi.fn() readonly hide = vi.fn() @@ -71,7 +73,7 @@ const harness = await vi.hoisted(async () => { readonly setSize = vi.fn() readonly setTitleBarOverlay = vi.fn() constructor(readonly options: { show: boolean; modal?: boolean }) { - super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve() + super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) } isDestroyed() { return this.destroyed } fullscreen = false @@ -468,7 +470,7 @@ describe('desktop main startup', () => { expect(testAuth.login).not.toHaveBeenCalled() }) - it('retains the same blocking window and running Host after expired test login is cancelled', async () => { + it('retains the embedded block and running Host after expired test login is cancelled', async () => { harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } const request = vi.fn().mockImplementation(async () => @@ -479,7 +481,7 @@ describe('desktop main startup', () => { const host = await readyForUpdate() await harness.policyBlocked.promise await vi.advanceTimersByTimeAsync(1000) - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) testAuth.login.mockResolvedValueOnce('cancelled') @@ -867,14 +869,15 @@ describe('desktop main startup', () => { vi.stubGlobal('fetch', request) const host = await readyForUpdate() await harness.policyBlocked.promise - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! expect(modal).toBeDefined() + expect(harness.windows).toHaveLength(1) const status = harness.handlers.get(MANDATORY_IPC.status)! const action = harness.handlers.get(MANDATORY_IPC.action)! const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } expect(status(owned)).toMatchObject({ policy: { blocking: true } }) const unowned = [ - { ...owned, sender: harness.windows[0]!.webContents }, + { ...owned, sender: {} }, { ...owned, senderFrame: { url: 'dsh-app://app/index.html' } }, { ...owned, senderFrame: { url: 'https://untrusted.example.com/' } }, ] @@ -899,7 +902,8 @@ describe('desktop main startup', () => { await vi.advanceTimersByTimeAsync(20_000) expect(modal.isDestroyed()).toBe(false) await vi.advanceTimersByTimeAsync(150) - expect(modal.isDestroyed()).toBe(true) + expect(modal.isDestroyed()).toBe(false) + expect(modal.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: false } }]) expect(host.stop).not.toHaveBeenCalled() expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' }) }) @@ -1115,7 +1119,7 @@ describe('desktop main startup', () => { }) async function answerMandatory(action: 'install' | 'later') { - const modal = harness.windows.find(window => window.options.modal && !window.isDestroyed())! + const modal = harness.windows[0]! const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } await vi.waitFor(() => { expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation') @@ -1124,14 +1128,14 @@ describe('desktop main startup', () => { await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision) } - it('releases the mandatory modal when the confirmed installer quits Electron', async () => { + it('closes the main window when the confirmed installer quits Electron', async () => { harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', } }))) const host = await readyForUpdate() await harness.policyBlocked.promise - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! const preparing = harness.prepareUpdate() await answerMandatory('install') await host.stopping.promise @@ -1175,7 +1179,10 @@ describe('desktop main startup', () => { await expect(retry).resolves.toBe(false) expect(replacement.updateTasks.mock.calls).toEqual([['inspect']]) expect(replacement.stop).not.toHaveBeenCalled() - if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false) + if (mandatory) { + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.isDestroyed()).toBe(false) + } }) it.each([false, true])('restores a confirmed non-graceful exit without approving installation, mandatory: %s', async (mandatory) => { @@ -1209,7 +1216,10 @@ describe('desktop main startup', () => { await expect(retry).resolves.toBe(false) expect(replacement.updateTasks.mock.calls).toEqual([['inspect']]) expect(replacement.stop).not.toHaveBeenCalled() - if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false) + if (mandatory) { + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.isDestroyed()).toBe(false) + } }) it('does not replace a Host whose failed stop has not confirmed process exit', async () => { diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index 84f9195122..a1c218852a 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -13,7 +13,7 @@ vi.mock('electron', () => ({ ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler), removeHandler: (channel: string) => native.handlers.delete(channel), }, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } })) -vi.mock('../src/update-overlay.ts', () => ({ createMandatoryUpdateWindow: () => window })) +vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: vi.fn(() => window) })) let window: ReturnType let ui: DesktopMandatoryUpdateWindow | undefined @@ -21,12 +21,14 @@ function fakeWindow() { const contents = Object.assign(new EventEmitter(), { mainFrame: { url: 'dsh-app://shell/mandatory-update.html' }, send: vi.fn(), setWindowOpenHandler: vi.fn() }) return Object.assign(new EventEmitter(), { webContents: contents, setMenu: vi.fn(), setTitle: vi.fn(), - loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false, + loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: (): boolean => false, isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() }) } -afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() }) -function setup() { +afterEach(() => { ui?.dispose(); vi.restoreAllMocks(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() }) +function setup(platform: NodeJS.Platform = 'darwin') { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) window = fakeWindow() + if (platform === 'win32') window.webContents.mainFrame.url = 'dsh-app://app/' let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' } let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' } const install = vi.fn(async () => update) @@ -42,6 +44,16 @@ function setup() { update(next: DesktopUpdateState) { update = next; ui!.sync() } } } +it('releases IPC after the Windows main window has already been destroyed', () => { + setup('win32') + vi.spyOn(window, 'isDestroyed').mockReturnValue(true) + Object.defineProperty(window, 'webContents', { get() { throw new Error('Object has been destroyed') } }) + expect(() => { ui!.sync(); ui!.focus() }).not.toThrow() + expect(() => { ui!.dispose() }).not.toThrow() + expect(native.handlers.size).toBe(0) + expect(() => { ui!.dispose() }).not.toThrow() +}) + it('waits for a version-bound second click in the same modal and rejects obsolete or hidden responses', async () => { const f = setup() const pending = ui!.confirm('1.0.1-nightly.1', false) @@ -152,3 +164,38 @@ it('fades on clearance, reuses a reblocked window, and cancels teardown on dispo ui!.dispose() expect(vi.getTimerCount()).toBe(0) }) + +it('publishes Windows overlays into the main document without creating or destroying a window', () => { + const f = setup('win32') + expect(window.loadURL).not.toHaveBeenCalled() + expect(ui!.confirmationWindow).toBe(window) + expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, f.view()) + window.webContents.emit('did-finish-load') + expect(window.webContents.send).toHaveBeenCalledTimes(2) + const event = { sender: window.webContents, senderFrame: { ...window.webContents.mainFrame } } + expect(() => native.handlers.get(MANDATORY_IPC.status)!(event)).toThrow(/unowned/) + f.policy({ blocking: false, checking: false }) + expect(window.destroy).not.toHaveBeenCalled() + ui!.dispose() + expect(window.destroy).not.toHaveBeenCalled() + expect(window.webContents.listenerCount('did-finish-load')).toBe(0) +}) + +it('rebinds policy updates and reloads to a replacement Windows main window', () => { + setup('win32') + const previous = window + window = fakeWindow() + window.webContents.mainFrame.url = 'dsh-app://app/?recovery=1#home' + ui!.sync() + expect(previous.webContents.listenerCount('did-finish-load')).toBe(0) + expect(window.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: true } }]) + window.webContents.send.mockClear() + window.webContents.emit('did-finish-load') + expect(window.webContents.send).toHaveBeenCalledOnce() + expect(() => native.handlers.get(MANDATORY_IPC.status)!({ + sender: previous.webContents, senderFrame: previous.webContents.mainFrame, + })).toThrow(/unowned/) + expect(() => native.handlers.get(MANDATORY_IPC.status)!({ + sender: window.webContents, senderFrame: window.webContents.mainFrame, + })).not.toThrow() +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index b095aae2c0..fb633e8a4a 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,4 +1,5 @@ import { afterEach, expect, it, vi } from 'vitest' +import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' import { syncWindowsAppearance } from '../src/preload-windows.ts' import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' @@ -10,8 +11,9 @@ vi.mock('electron', () => electron) vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) +vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) @@ -76,3 +78,14 @@ it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://examp expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0) }, ) + +it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) { + vi.resetModules() + vi.mocked(installMandatoryUpdateOverlay).mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0) + } +}) diff --git a/apps/desktop/tests/preload-mandatory-overlay.spec.ts b/apps/desktop/tests/preload-mandatory-overlay.spec.ts new file mode 100644 index 0000000000..8416e89fac --- /dev/null +++ b/apps/desktop/tests/preload-mandatory-overlay.spec.ts @@ -0,0 +1,104 @@ +import { MessageChannel } from 'node:worker_threads' +import { JSDOM } from 'jsdom' +import { afterEach, expect, it, vi } from 'vitest' +import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' +import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts' +import { resolveDesktopLocale } from '../src/locale.ts' +import type { MandatoryUpdateView } from '../src/mandatory-update-window.ts' + +const ipc = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), invoke: vi.fn(async () => {}) })) +vi.mock('electron', () => ({ ipcRenderer: ipc })) +let dom: JSDOM +let channel: MessageChannel +afterEach(() => { + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + dom.window.close() + channel?.port1.close() + channel?.port2.close() + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.clearAllMocks() + vi.useRealTimers() +}) + +function setup() { + dom = new JSDOM('
', { url: 'dsh-app://app/' }) + vi.stubGlobal('window', dom.window) + vi.stubGlobal('document', dom.window.document) + vi.stubGlobal('HTMLElement', dom.window.HTMLElement) + vi.stubGlobal('MessageChannel', function createChannel() { channel = new MessageChannel(); return channel }) + vi.spyOn(dom.window.document, 'readyState', 'get').mockReturnValue('complete') + const shadow = vi.spyOn(dom.window.HTMLElement.prototype, 'attachShadow') + installMandatoryUpdateOverlay() + const publish = ipc.on.mock.calls.find(([name]) => name === MANDATORY_IPC.state)![1] as + (event: unknown, state: MandatoryUpdateView) => void + const view: MandatoryUpdateView = { locale: resolveDesktopLocale('zh-CN'), policy: { blocking: true, checking: false }, + update: { phase: 'available', version: '2.0.0' }, deferred: false } + publish({}, view) + ipc.invoke.mockClear() + const root = shadow.mock.results[0]!.value as ShadowRoot + const frame = root.querySelector('iframe')! + // JSDOM has no browsing context for a shadow-root iframe; transport uses real paired Node ports. + const post = vi.spyOn(dom.window, 'postMessage').mockImplementation(() => {}) + Object.defineProperty(frame, 'contentWindow', { value: dom.window }) + const focus = vi.spyOn(frame, 'focus').mockImplementation(() => {}) + frame.dispatchEvent(new dom.window.Event('load')) + expect(post).toHaveBeenCalledWith({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2]) + return { publish, view, root, frame, focus, host: root.host as HTMLElement } +} + +it('uses an in-page frame below the caption and reuses it on updates', () => { + const f = setup() + expect(f.host.style.top).toBe('40px') + expect(f.host.style.bottom).toBe('0px') + expect(f.frame.src).toBe('dsh-app://shell/mandatory-update.html') + expect(dom.window.document.body.style.filter).toBe('') + f.publish({}, { ...f.view, update: { phase: 'downloading', version: '2.0.0', percent: 30 } }) + expect(f.root.querySelector('iframe')).toBe(f.frame) +}) + +it('rejects forged window events and accepts actions only through the transferred private port', async () => { + const f = setup() + const data = { type: 'dsh-mandatory-action', id: 1, action: 'download', version: '2.0.0', revision: undefined } + dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source: f.frame.contentWindow, origin: 'dsh-app://shell', data })) + expect(ipc.invoke).not.toHaveBeenCalled() + const result = new Promise(resolve => channel.port2.on('message', (message: unknown) => { + if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'dsh-mandatory-result') resolve(message) + })) + channel.port2.postMessage(data) + await expect(result).resolves.toEqual({ type: 'dsh-mandatory-result', id: 1, ok: true }) + expect(ipc.invoke).toHaveBeenCalledExactlyOnceWith(MANDATORY_IPC.action, 'download', '2.0.0', undefined) +}) + +it('keeps caption menu keyboard events available while blocking background shortcuts', () => { + const f = setup() + const host = dom.window.document.querySelector('[data-windows-menu]')! + const shadow = host.attachShadow({ mode: 'open' }) + const button = dom.window.document.createElement('button') + shadow.append(button) + const received = vi.fn() + button.addEventListener('keydown', received) + for (const key of ['Enter', 'ArrowDown', 'ArrowRight']) { + const event = new dom.window.KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }) + button.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + } + expect(received).toHaveBeenCalledTimes(3) + const background = new dom.window.KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true }) + dom.window.document.body.dispatchEvent(background) + expect(background.defaultPrevented).toBe(true) + expect(f.focus).toHaveBeenCalled() +}) + +it('clears once without postponing removal on progress ticks, and closes the channel on navigation', () => { + vi.useFakeTimers() + const f = setup() + const cleared = { ...f.view, policy: { blocking: false, checking: false } } + f.publish({}, cleared) + vi.advanceTimersByTime(100) + f.publish({}, cleared) + vi.advanceTimersByTime(50) + expect(f.host.isConnected).toBe(false) + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + expect(ipc.off).toHaveBeenCalledWith(MANDATORY_IPC.state, expect.any(Function)) +}) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 97bb5cc266..9f4a79878d 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -1,31 +1,11 @@ import { EventEmitter } from 'node:events' import { expect, it, vi } from 'vitest' import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' -import { createMandatoryUpdateWindow } from '../src/update-overlay.ts' +import { createUpdateOverlay } from '../src/update-overlay.ts' const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() })) vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } })) -it('gives the Windows mandatory modal native move, resize, and maximize controls', () => { - const window = Object.assign(new EventEmitter(), { - webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), isDestroyed: () => false, - }) - native.create.mockReturnValue(window) - const parent = {} as BrowserWindow - expect(createMandatoryUpdateWindow(parent, 'owned', 'Update required', 'win32')).toBe(window) - expect(native.create).toHaveBeenCalledWith(expect.objectContaining({ - parent, modal: true, show: false, title: 'Update required', - movable: true, resizable: true, maximizable: true, - minWidth: 480, minHeight: 360, - })) - const options = native.create.mock.calls[0]![0] - expect(options.webPreferences).toMatchObject({ preload: 'owned', sandbox: true, nodeIntegration: false }) - expect(options).not.toHaveProperty('frame', false) - window.emit('ready-to-show') - expect(window.show).toHaveBeenCalledOnce() - expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce() -}) - it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const parent = Object.assign(new EventEmitter(), { @@ -39,7 +19,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu }) native.create.mockReturnValue(window) try { - createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin') + createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) const event = { preventDefault: vi.fn() } parent.webContents.emit('before-input-event', event) diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 21e4f78bae..2cf02b02eb 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -318,7 +318,7 @@ describe('web e2e: clickable links gallery', () => { await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME }) browser = await chromium.launch() page = await newEnglishPage(browser) - await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ + await page.context().route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ contentType: 'text/html', body: `

${new URL(route.request().url()).pathname}

`, })) @@ -454,5 +454,31 @@ describe('web e2e: clickable links gallery', () => { await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) await markdown.locator(`a[href="${HTTP_URL}"]`).click() await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL) + + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Built-in browser', exact: true }).click() + await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'new-tab' }) + await page.keyboard.press('Escape') + const popupPromise = page.waitForEvent('popup') + await guideLink.click() + const popup = await popupPromise + try { + await popup.waitForURL(GUIDE_URL) + expect(await popup.evaluate(() => window.opener === null)).toBe(true) + expect(await browserAddress.inputValue()).toBe(HTTP_URL) + } finally { + await popup.close() + } + + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'New browser tab', exact: true }).click() + await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'sidebar' }) + await page.keyboard.press('Escape') + await guideLink.click() + await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) + expect(tripwire.pageErrors).toEqual([]) }, 90_000) }) diff --git a/apps/web/tests/clickable-links-gallery.overlay.yml b/apps/web/tests/clickable-links-gallery.overlay.yml index d8adc80e3b..ad0114d151 100644 --- a/apps/web/tests/clickable-links-gallery.overlay.yml +++ b/apps/web/tests/clickable-links-gallery.overlay.yml @@ -7,3 +7,5 @@ - id: settings-controller config: nativeOpen: true +- id: ui-sidebar-browser + disabled: false diff --git a/apps/web/tests/deepseek-messages-chat.e2e.ts b/apps/web/tests/deepseek-messages-chat.e2e.ts index 36b37a70ab..d6bbf88260 100644 --- a/apps/web/tests/deepseek-messages-chat.e2e.ts +++ b/apps/web/tests/deepseek-messages-chat.e2e.ts @@ -24,7 +24,6 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', () beforeAll(async () => { replayFixture = await selectedSessionFixture(FIXTURE, false) scaffold = await launchWebScaffold({ - deepSeekMessages: true, replayFixture, paceMs: 5, replayProviders: [{ @@ -36,6 +35,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', () }], }], }) + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 32fd2a613e..3018cc432e 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -1,4 +1,4 @@ -/** Opt-in Web Messages configuration, credential reuse, and recovery from a saved Chat Completions selection. */ +/** Web DeepSeek configuration, credential reuse, and saved model selection. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -12,14 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url)) -describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => { +describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages settings', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true }) + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) @@ -68,7 +68,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt- await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({ name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', }) - expect(scaffold.ctx.settings.get('llm-deepseek')).toMatchObject({ protocol: 'messages' }) + expect(scaffold.ctx.settings.get('llm-deepseek')).not.toHaveProperty('protocol') expect(settings).not.toContain('sk-e2e-') const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages') @@ -84,7 +84,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt- expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('keeps a saved Chat Completions selection available after the YAML protocol switch', async () => { + it('keeps a saved DeepSeek selection available after editing provider settings', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default')) await page.keyboard.press('Escape') await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 7df4c7ce8a..0fd9f06075 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - textbox "模型 ID 1": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index be77e20835..b11ef1f610 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - textbox "模型 ID 1": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 9e344cc148..a6f4185486 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/plugin-manager/icons.expected.md b/apps/web/tests/expected/plugin-manager/icons.expected.md new file mode 100644 index 0000000000..fb9df5edb1 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/icons.expected.md @@ -0,0 +1,5 @@ +Third-party bundle card: image, 36×36, decoded +Disabled Agent Teams card: image, 36×36, decoded +Agent Teams detail: image, 36×36, decoded +Third-party bundle detail: image, 36×36, decoded +Independent search row: image, 30×30, decoded diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md new file mode 100644 index 0000000000..41d6462e11 --- /dev/null +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -0,0 +1,28 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General" + - button "Models" + - button "Built-in plugins" + - button "Agent presets" + - button "Open configuration file" + - button "Close" + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write" + - text: Language + - button "English" + - text: Appearance + - button "Light" + - button "Dark" + - button "System" [pressed] + - text: Font size Only affects conversation content 14 + - button "Increase font size" + - button "Decrease font size" + - text: px Conversation display Controls process content in completed turns + - button "Compact" + - text: Performance & usage Choose how much performance and usage information to show + - button "Detailed" + - text: Developer tools Show tools and information for debugging and troubleshooting + - switch "Developer tools" + - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior + - button "Queue" diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg b/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg new file mode 100644 index 0000000000..ad85688706 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json index 3224dfa296..71aa6bbd5f 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -1,6 +1,7 @@ { "name": "@fixture/bundle", "version": "0.0.1", + "icon": "./icon.svg", "private": true, "description": "Registry description for the fixture bundle.", "type": "module", diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json index 52a0c0f420..79083aafff 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json @@ -1,4 +1,5 @@ { "name": "Search plugin", + "icon": "./icon.svg", "description": "Search package introduction." } diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 716176a176..87adea12cc 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -1,4 +1,4 @@ -/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */ +/** Built dsh web + the `pnpm run dev:web --no-serve` watchers → browser HMR, with no page reload. */ import { existsSync, globSync, statSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -108,12 +108,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a const failures: unknown[] = [] try { subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime) + // Watchers only: the built `dsh web` below is the server under test, and the + // built tree is this lane's precondition rather than something to rebuild. watcher = subprocessCtx.subprocess.spawn(spawnSpec( - ['pnpm', 'run', 'dev:web'], + ['pnpm', 'run', 'dev:web', '--skip-build', '--no-serve'], REPO_ROOT, { ...clientBuildEnvironment }, )) - await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') + await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web --skip-build --no-serve') host = subprocessCtx.subprocess.spawn(spawnSpec( [process.execPath, binPath, 'web', '--no-open', '--port', '0'], world, diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index 8fb8b5942c..fc7392c433 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -92,7 +92,9 @@ describe('web e2e: Markdown inline-code links', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({}) + scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) diff --git a/apps/web/tests/no-sidebar-browser.overlay.yml b/apps/web/tests/no-sidebar-browser.overlay.yml new file mode 100644 index 0000000000..2af93975a4 --- /dev/null +++ b/apps/web/tests/no-sidebar-browser.overlay.yml @@ -0,0 +1,3 @@ +# Exercise the settings surface without a built-in browser provider. +- id: ui-sidebar-browser + disabled: true diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 373b0ba459..8ce5950d4c 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -140,6 +140,50 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('decodes manifest icons for disabled bundles and independent plugin rows', async () => { + const panel = await openPluginsPanel() + const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}` + const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}` + const images: string[] = [] + const checkImage = async (selector: string, source: string, label: string) => { + const image = panel.locator(`${selector} img`).first() + await image.waitFor() + expect(await image.getAttribute('src')).toBe(source) + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + const size = await image.evaluate((node: HTMLImageElement) => ({ + width: node.width, height: node.height, naturalWidth: node.naturalWidth, + })) + expect(size.naturalWidth).toBeGreaterThan(0) + images.push(`${label}: image, ${size.width}×${size.height}, decoded`) + } + await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card') + const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]') + expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false') + await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]', teamIcon, 'Disabled Agent Teams card') + try { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + expect(await team.locator('img').evaluate((node: HTMLImageElement) => node.naturalWidth)).toBe(36) + if (MODE === 'refresh') { + const path = fileURLToPath(new URL(`../../../.artifacts/plugin-icons-${process.pid}-${colorScheme}.png`, import.meta.url)) + await page.screenshot({ path }) + console.log(`Plugin icon screenshot: ${path}`) + } + } + } finally { + await page.emulateMedia({ colorScheme: null }) + } + await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() + await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail') + await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() + await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') + await checkImage('[data-plugin-row="fixture-search"]', fixtureIcon, 'Independent search row') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE) + await panel.getByRole('button', { name: '返回插件列表' }).click() + expect(tripwire.pageErrors).toEqual([]) + }) + it('localizes independent exports and falls back per field without activating the plugins', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) const panel = await openPluginsPanel() @@ -331,7 +375,7 @@ describe('web e2e: plugin manager', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', + 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', ]) }) }) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index d8fccb3271..37fdd06073 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller' import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot' import { launchWebScaffold, webSnapshotMode } from './scaffold.ts' +const childFixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.1.v2.jsonl', import.meta.url)) const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url)) describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => { @@ -32,6 +33,33 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t await mkdir(dirname(predecessor), { recursive: true }) await writeFile(predecessor, source) + const childId = SessionId('preset-migration-child') + const brokenId = SessionId('preset-migration-broken-child') + const childFixture = (await readFile(childFixturePath, 'utf8')).trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + const childRows: Record[] = childFixture.slice(1).map((row, seq) => ({ ...row, seq, time: seq + 3 })) + const childHeader = { ...childFixture[0], id: childId, parentSession: id, cwd: scaffold.workspaceCwd } + const childSource = Buffer.concat([childHeader, ...childRows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n')))) + const childPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, 2, 'zstd') + const brokenPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, 2, 'zstd') + await mkdir(dirname(childPath), { recursive: true }) + await mkdir(dirname(brokenPath), { recursive: true }) + await writeFile(childPath, childSource) + await writeFile(brokenPath, Buffer.concat([ + zstdCompressSync(Buffer.from(JSON.stringify({ ...childHeader, id: brokenId }) + '\n')), + zstdCompressSync(Buffer.from('{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, + data: { turn: 1, reason: { kind: 'completed' } } }) + '\n')), + ])) + const brokenSource = await readFile(brokenPath) + const corruptHeaderId = SessionId('preset-migration-corrupt-header') + const corruptHeaderPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, corruptHeaderId, 2, 'zstd') + const corruptHeaderSource = Buffer.from('invalid Zstandard header frame') + await mkdir(dirname(corruptHeaderPath), { recursive: true }) + await writeFile(corruptHeaderPath, corruptHeaderSource) + const listed = await scaffold.ctx.sessionController.list({}, new AbortController().signal) + expect(listed.items.map(item => item.sessionId)).toEqual(expect.arrayContaining([id, childId, brokenId])) + expect(listed.items.map(item => item.sessionId)).not.toContain(corruptHeaderId) + const reader = await scaffold.ctx.sessionPersistence.open(id, 'read') try { expect(reader.header.agentPreset).toBe('ptc') @@ -48,7 +76,35 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) + expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([ + { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, + { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, + ]) + const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd') + await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) + const signal = new AbortController().signal + const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const + const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal) + expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type'])) + await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) + .rejects.toThrow() + await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) + .resolves.toMatchObject({ hasMore: false }) + const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write') + await childWriter.close() + const childPublished = await readFile(childSuccessor) + const childText = Buffer.concat(scanZstdFrames(childPublished).frames + .map(({ start, end }) => zstdDecompressSync(childPublished.subarray(start, end)))).toString('utf8') + expect(childText.trimEnd().split('\n').map((line): unknown => JSON.parse(line))).toEqual([ + { ...childHeader, version: SESSION_FORMAT_VERSION, agentPreset: 'ptc' }, ...childRows, + ]) + expect(await readFile(childPath)).toEqual(childSource) + expect(await readFile(brokenPath)).toEqual(brokenSource) + await expect(readFile(generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, SESSION_FORMAT_VERSION, 'zstd'))) + .rejects.toMatchObject({ code: 'ENOENT' }) + + await scaffold.ctx.sessions.flush(resolved.agent.session) const publishedBytes = await readFile(successor) const published = Buffer.concat(scanZstdFrames(publishedBytes).frames .map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8') @@ -58,12 +114,23 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t && (row['data'] as { agentPreset: string }).agentPreset === 'code' ? { ...row, data: { agentPreset: 'ptc' } } : row), + { type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: { + version: 1, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown', + } }, + { type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: { + version: 0, childId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child', + } }, // Agent activation closes its restored prefix with a fresh seed marker. - { type: 'session/end-seed', seq: rows.length, time: 0, data: {} }, + { type: 'session/end-seed', seq: rows.length + 2, time: 0, data: {} }, + { type: 'permission/preset', seq: rows.length + 3, time: 0, data: { preset: 'workspace-write' } }, + { type: 'sandbox/mode', seq: rows.length + 4, time: 0, data: { mode: 'workspace-write' } }, + { type: 'approval/policy', seq: rows.length + 5, time: 0, data: { policy: 'ask' } }, ].map(row => JSON.stringify(row)).join('\n') + '\n' const context = { sessionIds: [id], cwd: scaffold.workspaceCwd } expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context)) expect(await readFile(predecessor)).toEqual(source) + await expect(scaffold.ctx.sessionPersistence.open(corruptHeaderId, 'read')).rejects.toThrow(/invalid frame magic/) + expect(await readFile(corruptHeaderPath)).toEqual(corruptHeaderSource) expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort()) .toEqual(['session.v2.jsonl.zstd', `session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`]) } finally { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index fa3e98dd35..b02ae8b91a 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -261,10 +261,9 @@ class RouteOnlyAdapter extends LlmAdapter { } } -function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS { +function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS { return REPLAY_PROVIDERS.map(provider => ({ ...provider, - id: messages ? 'deepseek-messages' : provider.id, models: provider.models.map(model => ({ ...model, ...contextWindow === undefined ? {} : { contextWindow }, @@ -391,8 +390,6 @@ export interface LaunchOptions { * keyless first-run configuration lane; the default disables the adapter. */ deepSeekMissingCredential?: boolean - /** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */ - deepSeekMessages?: boolean /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */ welcomeNoticePending?: boolean /** @@ -494,7 +491,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { @@ -571,13 +567,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ + providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({ ...provider, ...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }), })), @@ -885,8 +874,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ctx.llm.registerAdapter( - replayProviders(options.replayContextWindow, messages).map(provider => provider.id), - new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)), + replayProviders(options.replayContextWindow).map(provider => provider.id), + new RouteOnlyAdapter(replayProviders(options.replayContextWindow)), ), 'web e2e scaffold: route-only adapter') } if (publicProxy === undefined || publicHost === undefined || publicPrefix === undefined) { diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index d84614d33c..c5e0d10fb6 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -187,6 +187,50 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('scrolls overflowing section navigation independently of the title and settings content', async () => { + const overflowPage = await browser.newPage({ viewport: { width: 1280, height: 768 }, locale: ZH_BROWSER_LOCALE }) + onTestFinished(() => overflowPage.close()) + onTestFailed(() => saveFailureShot(overflowPage, 'web-e2e-settings-navigation')) + const console = watchConsole(overflowPage) + await overflowPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await overflowPage.getByRole('button', { name: '设置', exact: true }).click() + const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true }) + const navigation = dialog.getByRole('navigation') + const list = navigation.locator('[class*="_navList"]') + const title = navigation.getByText('设置', { exact: true }) + const options = dialog.locator('[class*="_options"]') + const last = navigation.getByRole('button').last() + await last.waitFor() + expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(false) + + // A short viewport overflows the shipped sections without synthetic registrations. + await overflowPage.setViewportSize({ width: 1280, height: 240 }) + expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true) + const titleBefore = await title.boundingBox() + const contentBefore = await options.innerText() + await navigation.getByRole('button').first().hover() + await overflowPage.mouse.wheel(0, 1000) + await expect.poll(() => last.evaluate((el) => { + const rect = el.getBoundingClientRect() + return el.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)) + })).toBe(true) + const navScroll = await list.evaluate(el => el.scrollTop) + expect(navScroll).toBeGreaterThan(0) + expect(await title.boundingBox()).toEqual(titleBefore) + expect(await options.evaluate(el => el.scrollTop)).toBe(0) + + await options.hover() + await overflowPage.mouse.wheel(0, 1000) + await expect.poll(() => options.evaluate(el => el.scrollTop)).toBeGreaterThan(0) + expect(await list.evaluate(el => el.scrollTop)).toBe(navScroll) + expect(await title.boundingBox()).toEqual(titleBefore) + await last.click() + await expect.poll(() => last.getAttribute('aria-current')).toBe('true') + await expect.poll(() => options.innerText()).not.toBe(contentBefore) + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + }) + it('stores Permission as the default for future sessions without changing an existing session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) @@ -722,10 +766,31 @@ describe('web e2e: settings modal and General preferences', () => { } }, 90_000) + it('hides link-opening settings when the built-in browser is disabled', async () => { + const fresh = await launchWebScaffold({ + developerTools: false, + extraOverlayPath: fileURLToPath(new URL('./no-sidebar-browser.overlay.yml', import.meta.url)), + }) + onTestFinished(() => fresh.close()) + const withoutBrowser = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) + onTestFinished(() => withoutBrowser.close()) + const browserConsole = watchConsole(withoutBrowser) + await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) + await withoutBrowser.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor() + expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE) + expect(browserConsole.pageErrors).toEqual([]) + expect(browserConsole.warnings).toEqual([]) + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'dialog-en.expected.md', + 'dialog-no-browser.expected.md', 'dialog.expected.md', 'plugin-instances.expected.md', 'plugins.expected.md', diff --git a/apps/web/tests/sidebar-browser.e2e.ts b/apps/web/tests/sidebar-browser.e2e.ts index 7a3334bb1b..ca26a11a4c 100644 --- a/apps/web/tests/sidebar-browser.e2e.ts +++ b/apps/web/tests/sidebar-browser.e2e.ts @@ -29,7 +29,11 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + scaffold = await launchWebScaffold({ + replayFixture: FIXTURE, + compareReplaySession: false, + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) browser = await chromium.launch() page = await newEnglishPage(browser) await page.route('https://browser.test/**', async (route) => { diff --git a/apps/web/tests/sidebar-browser.overlay.yml b/apps/web/tests/sidebar-browser.overlay.yml new file mode 100644 index 0000000000..66012abfce --- /dev/null +++ b/apps/web/tests/sidebar-browser.overlay.yml @@ -0,0 +1,3 @@ +# Opt in to Browser for its integration scenarios. +- id: ui-sidebar-browser + disabled: false diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 49be02de85..90571db3f4 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -409,7 +409,8 @@ describe('web e2e: shipped right Sidebar', () => { } await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) - await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(3) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) + expect(await column.locator('[data-sidebar-right-guide-entry="browser"]').count()).toBe(0) await column.locator('[data-sidebar-right-guide-entry="files"]').click() // A manual guide is closable beside Files and suppresses another add diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ddf3683c59..9984bf38de 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,7 +10,7 @@ import { productWebBundleIsolation } from './product-isolation.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. ' - + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.' + + 'For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together.' const DEFAULT_CLIENT_TITLE = 'DSH Local Build' /** Escape build-time text before placing it in the HTML title element. */ diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index fa62039df7..673807013b 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 2cf7b2f345991fd20639e1294662cc64fc02093d -api-gateway.zh.md: 5b80db08de49591b27751e187aa6278384a7444e +api-gateway.md: aa8339219a563901d5d132f4f401325ca30a3d53 +api-gateway.zh.md: 99b2d9866d38e520880e5d13115c2e6370fca474 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2cf7b2f345..aa8339219a 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -138,14 +138,13 @@ SRC solves only dispatch for a Host process running from source. The Client does ## Development mode -Web development prepares current Host, Client, and Web artifacts with `pnpm run build`, then runs the source Host and the Client plugin watcher in separate terminals: +Web development runs one command, which builds current Host, Client, and Web artifacts, starts the source Host, and keeps Client bundles rebuilt: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dsh.client` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. +`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; the `dev:web` watchers rebuild the client-face type emit, Client plugin and library bundles, and the Web shell on source edits. They do not analyze Host decorators or generate Remote Client DTS; only the complete build that `dev:web` runs first (or `pnpm run build`) does. Changing only a Remote method's implementation body without changing its contract does not require regenerating the Typert files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 5b80db08de..99b2d9866d 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -138,14 +138,13 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho ## 开发模式 -Web 开发先使用 `pnpm run build` 准备当前 Host、Client 与 Web 产物,然后在两个终端中分别运行源码 Host 和 Client plugin watcher: +Web 开发只需一条命令:它先构建当前 Host、Client 与 Web 产物,再启动源码 Host,并持续重建 Client bundle: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dsh.client` 声明的 Client 插件并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 +`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 的 watcher 会在源码修改时重建 client 侧类型输出、Client 插件与库 bundle 以及 Web 壳层。它们不会分析 Host decorator,也不会生成 Remote Client DTS;只有 `dev:web` 首先执行的完整构建(或 `pnpm run build`)才会。 只修改 Remote 方法实现体而不改变约定时,无需重新生成 Typert 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格约定,再让 Client 编译并打包新的贡献: diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3d9fba9385..66b457c8fa 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 0bbf5ef7f4c0444635178ba0ba85fbb9527d76ec -architecture.zh.md: 0d28dc349fd9c655529bcc43deabc47055e0d394 +architecture.md: 5e0bb09770e013349bcc1326bea1eab9cdc3e595 +architecture.zh.md: a146c93c14ef0f563babf98f7311f1e5686895cc diff --git a/docs/architecture.md b/docs/architecture.md index 0bbf5ef7f4..5e0bb09770 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof Supported Node applications launch through named `dsh` profiles. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile ` or `dsh `. `plugin` names the management command; a profile with that name requires `--profile plugin`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree. -Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. +Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, root demo, and the root `start:web` and `dev:web` scripts in an explicit class and rejects a Node application path that bypasses `dsh`. The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0d28dc349f..a146c93c14 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -44,7 +44,7 @@ dsh --profile web --dump-config 受支持的 Node 应用通过具名 `dsh` profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile ` 或 `dsh ` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。 -Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 +Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码、根 demo 以及根脚本 `start:web` 与 `dev:web` 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 45782b3b20..08ba301635 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: be92cd419bfc60406cd6aad3986e80f031d37fa9 -config-catalog.zh.md: 6cb3680db988fe8f1a76cbed8986fff5d8ca715e +config-catalog.md: 4c89900a886e06f58d581af305bfa080b2d92d6a +config-catalog.zh.md: ff41610c361e1e1265f3201f8d62842195e7bf98 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index be92cd419b..4c89900a88 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1161,8 +1161,6 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** Wire protocol; defaults to messages. Configure through Cordis YAML. */ - protocol?: DeepSeekProtocol /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ @@ -1203,9 +1201,6 @@ export interface Config { retryPolicy?: RetryPolicyConfig } -/** Supported wire implementations; Responses is not yet implemented. */ -export type DeepSeekProtocol = 'chat-completions' | 'messages' - /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ @@ -2682,7 +2677,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent/src/index.ts:178`](../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:175`](../packages/subagent/subagent/src/index.ts) @@ -2735,7 +2730,7 @@ export interface Config { export type PermissionPolicy = 'allow' | 'reject' ``` -Source: [`packages/subagent/subagent-acp/src/index.ts:27`](../packages/subagent/subagent-acp/src/index.ts) +Source: [`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 6cb3680db9..ff41610c36 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1163,8 +1163,6 @@ export interface Config { * reasoning effort resolves to `high`. */ export interface Config { - /** Wire protocol; defaults to messages. Configure through Cordis YAML. */ - protocol?: DeepSeekProtocol /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ @@ -1205,9 +1203,6 @@ export interface Config { retryPolicy?: RetryPolicyConfig } -/** Supported wire implementations; Responses is not yet implemented. */ -export type DeepSeekProtocol = 'chat-completions' | 'messages' - /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ @@ -2737,7 +2732,7 @@ export interface Config { export type PermissionPolicy = 'allow' | 'reject' ``` -来源:[`packages/subagent/subagent-acp/src/index.ts:27`](../packages/subagent/subagent-acp/src/index.ts) +来源:[`packages/subagent/subagent-acp/src/index.ts:26`](../packages/subagent/subagent-acp/src/index.ts) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index b2b0467564..8c94de4306 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 8c818bc371b4fcd646878ec440dd713f8c116083 -adding-a-package.zh.md: f6a09b411e246c50fcf3f44117c05433b12cbd4c +adding-a-package.md: 60b5d6e047da5f9cdfab3ab504104a0f29b208f7 +adding-a-package.zh.md: 44a613bcf002009c4c58a34c0f68cab85ef097f3 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 8c818bc371..60b5d6e047 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -143,7 +143,9 @@ Fields fall back independently before view-specific name formatting, using the e - Title: locale `meta.title` → `package.json.name` → complete Cordis plugin name. - Description: locale `meta.description` → `package.json.description` → no description. -Export `/locale/en.json` for locale lookup; expose `/package.json` when package-field fallback is needed. +Export `/locale/en.json` for locale lookup; expose `/package.json` for package-field fallback or an icon declaration. + +For an image on bundle cards, details, and component rows, set top-level `"icon": "./icon.svg"` in that exported manifest and include the image in `files`. The path is relative to the declaring manifest's directory, including for independently exported plugin manifests. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. Images need no separate export and must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text; missing or undecodable images use the panel's default artwork. Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index f6a09b411e..44a613bcf0 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -143,7 +143,9 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex - 标题:locale `meta.title` → `package.json.name` → 完整 Cordis 插件名。 - 描述:locale `meta.description` → `package.json.description` → 不显示描述。 -导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退时,再开放 `<包名>/package.json`。 +导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退或声明图标时,开放 `<包名>/package.json`。 + +要在组合包卡片、详情和组件行显示图片,在该导出清单顶层设置 `"icon": "./icon.svg"`,并将图片加入 `files`。路径相对于声明清单所在目录,独立导出的插件清单也遵循此规则。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。图片不需要单独导出,且必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本;图片缺失或无法解码时使用面板的默认插画。 已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index 7329ae0f17..547583134d 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md -deepseek-llm-api-wire-extensions.md: f45508f375d3e624b4d5eded26393a737c3a0907 -deepseek-llm-api-wire-extensions.zh.md: ff72d62a9cdc2a33f6d6fd5fb08975b73839cae7 +deepseek-llm-api-wire-extensions.md: 47ecc9db5438d5992f3b84c1ba3cc9b15561dc2b +deepseek-llm-api-wire-extensions.zh.md: d79f97fc3a029cce04801cdf58eb713007b2eb43 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index f45508f375..47ecc9db54 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -2,7 +2,7 @@ English | [中文](deepseek-llm-api-wire-extensions.zh.md) -This reference defines every DeepSeek Harness-specific HTTP header and additive JSON field sent by [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.md) on `deepseek-official` Messages and Chat Completions requests. It does not redefine fields owned by the upstream DeepSeek API. The provider-neutral LLM interface and `llm-pi-ai` do not implement these additions. +This reference defines every DeepSeek Harness-specific HTTP header and additive JSON field sent by [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.md) on `deepseek-official` Messages requests. It does not redefine fields owned by the upstream DeepSeek API. The provider-neutral LLM interface and `llm-pi-ai` do not implement these additions. The adapter sends the additions to its resolved `baseURL`, including a configured gateway. They remain outside `messages`, system prompts, and tool schemas, so they do not add model-input tokens or alter the model-visible prefix. diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index ff72d62a9c..d79f97fc3a 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -2,7 +2,7 @@ [English](deepseek-llm-api-wire-extensions.md) | 中文 -本参考文档定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` Messages 与 Chat Completions 请求中发送的全部 DeepSeek Harness 特有 HTTP 标头和附加 JSON 字段。本文不重复定义 DeepSeek 上游 API 持有的字段。提供方无关的 LLM(大语言模型)接口与 `llm-pi-ai` 均不实现这些扩展。 +本参考定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` Messages 请求中发送的全部 DeepSeek Harness 专用 HTTP 标头与附加 JSON 字段。它不重新定义上游 DeepSeek API 自有字段。提供方无关的 LLM 接口与 `llm-pi-ai` 不实现这些扩展。 适配器将这些扩展发送至已解析的 `baseURL`,包括已配置的网关。扩展位于 `messages`、系统提示词和工具 schema 之外,因此不会增加模型输入 token,也不会改变模型可见前缀。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8959b05a1d..fd6198a8ca 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: ce8f1e7746562e696c96e6359ec9fcfde596d6e2 -development.zh.md: 85e95946dfd7210201f0807880d5ed1a92859356 +development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 +development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 diff --git a/docs/development.md b/docs/development.md index ce8f1e7746..7e8d0ded70 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. @@ -156,6 +156,19 @@ The PTC mode demo runs the same headless profile with code presentation enabled: pnpm run demo:ptc -- "summarize this workspace" ``` +### Application commands + +Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Arguments after a Web command reach `dsh web`, for example `pnpm run dev:web --no-open --port 3081`; `dev:web` also accepts `--skip-build` to reuse the existing artifact tree and `--no-serve` to run only the rebuild watchers beside a server started elsewhere. Both Web commands use the normal Harness home, while the Desktop commands use the isolated development home described in the [Desktop README](../apps/desktop/README.md). The root `Makefile` names the same commands as `make web`, `make dev-web`, `make desktop`, `make dev-desktop`, and `make build`; `ARGS='--no-open'` forwards options. + ### TODO markers Use one of three comment tags to flag known issues in the code, ordered by urgency: diff --git a/docs/development.zh.md b/docs/development.zh.md index 85e95946df..84d0d76d8c 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -87,7 +87,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 @@ -160,6 +160,19 @@ PTC mode 演示启用代码式工具展示,并运行同一个 headless profile pnpm run demo:ptc -- "summarize this workspace" ``` +### 应用命令 + +Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Web 命令后面的参数会传给 `dsh web`,例如 `pnpm run dev:web --no-open --port 3081`;`dev:web` 还接受 `--skip-build` 复用现有产物树,以及 `--no-serve` 只运行重建 watcher、配合别处启动的服务器。两个 Web 命令使用正常的 Harness home,而 Desktop 命令使用 [Desktop README](../apps/desktop/README.zh.md) 描述的隔离开发 home。根目录 `Makefile` 以 `make web`、`make dev-web`、`make desktop`、`make dev-desktop` 和 `make build` 命名同一套命令;`ARGS='--no-open'` 用于转发参数。 + ### TODO 标记 请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序: diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 77b35cdd01..f43b2ee216 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 3a312f0cdf694914905d8a138130805fe2ab69c9 -event-producer-consumer.zh.md: e8b7d1b7c2b25fa7a4d192fbe459352bdf9b35cf +event-producer-consumer.md: d312c39545d4f0ca62ea05b727e63f9b5c8755d6 +event-producer-consumer.zh.md: 1187c006b88c678c969554bc3d6851b38242e6bc diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e8b7d1b7c2..1187c006b8 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -64,10 +64,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:131`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 42ae2cc990..3890f7edf9 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 5192245aa1c2c8d32fef20ca172463ae7e85c1e6 -persistence-catalog.zh.md: 36d82def2a23f039adb4d65a670b6966cac35d1e +persistence-catalog.md: da664a192c53db44f3a08a461c6f21411a319e7a +persistence-catalog.zh.md: 03d8d52b0d6dc2c0b00872b1d2fd9cbac5add652 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 5192245aa1..da664a192c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a) | +| `event:subagent/catalog` | event | `289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -894,13 +894,13 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -2795,6 +2795,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -4555,7 +4563,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4563,14 +4571,17 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `d1506e1f9d875f1fec75d1de69dd0023d6c531d5008fb72d5e13ba16a6a62416` +SHA-256: `c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e` -Sources: [`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +Sources: [`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) One of: +- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391) - [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5636,6 +5647,21 @@ SHA-256: `0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe` | `mode` | required | `"one-shot"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, external?, label?, … }` + +SHA-256: `bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `external` | optional | [`union (6 variants)`](#persistence-type-sha256-ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1) | +| `label` | optional | `string` | +| `mode` | required | `"one-shot"` | +| `version` | required | `1` | + ### `{ childCreatedAt, childId, label, mode, … }` @@ -5650,6 +5676,34 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | required | `"continuable"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | required | `string` | +| `mode` | required | `"continuable"` | +| `version` | required | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"unknown"` | +| `version` | required | `1` | + ### `{ code, name, reason? }` @@ -8245,17 +8299,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a` +SHA-256: `289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-d1506e1f9d875f1fec75d1de69dd0023d6c531d5008fb72d5e13ba16a6a62416) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 36d82def2a..03d8d52b0d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -59,7 +59,7 @@ | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a) | +| `event:subagent/catalog` | event | `289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -896,13 +896,13 @@ export type SessionEvent = { ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -2797,6 +2797,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -4557,7 +4565,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4565,14 +4573,17 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `d1506e1f9d875f1fec75d1de69dd0023d6c531d5008fb72d5e13ba16a6a62416` +SHA-256: `c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e` -来源:[`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) 以下类型之一: +- [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391) - [`{ childCreatedAt, childId, external?, label?, … }`](#persistence-type-sha256-0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5638,6 +5649,21 @@ SHA-256: `0300cd374b1d3d9843f1a5f523c3c8fc920d2bc04be32380331359ac1edb1dbe` | `mode` | 必需 | `"one-shot"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, external?, label?, … }` + +SHA-256: `bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `external` | 可选 | [`union (6 variants)`](#persistence-type-sha256-ae2a48f2f3e53adc041eb3d7ee98a4880af2f81484c20c1d07a0f80dbfb7c0c1) | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"one-shot"` | +| `version` | 必需 | `1` | + ### `{ childCreatedAt, childId, label, mode, … }` @@ -5652,6 +5678,34 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | 必需 | `"continuable"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 必需 | `string` | +| `mode` | 必需 | `"continuable"` | +| `version` | 必需 | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"unknown"` | +| `version` | 必需 | `1` | + ### `{ code, name, reason? }` @@ -8247,17 +8301,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a` +SHA-256: `289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-d1506e1f9d875f1fec75d1de69dd0023d6c531d5008fb72d5e13ba16a6a62416) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml b/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml index 1f607875f0..e9884f53db 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-19-external-subagent-catalog.md -2026-09-19-external-subagent-catalog.md: cdf2c7315053a46cdaec74e89600f57044c43faa -2026-09-19-external-subagent-catalog.zh.md: d8a7c443f9f4faad591115cdeb66ef2aee2fc9d7 +2026-09-19-external-subagent-catalog.md: 6c5321567c8479ff7432f411833d5549dd7259fc +2026-09-19-external-subagent-catalog.zh.md: 6d51ab84c386ff07129569433991980851e2dd7e diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.md b/docs/persistence-changes/2026-09-19-external-subagent-catalog.md index cdf2c73150..6c5321567c 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.md +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.md @@ -9,7 +9,7 @@ English | [中文](2026-09-19-external-subagent-catalog.zh.md) ## Summary -Add an optional external execution outcome to parent catalog entries. +Add an optional external execution outcome to one-shot parent catalog entries. ## Table of Contents @@ -27,20 +27,20 @@ id: 2026-09-19-external-subagent-catalog baseline: false changes: - root: "event:subagent/catalog" - previous: "2026-09-11-initial" - after: "e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a" + previous: "2026-09-20-unknown-child-catalog" + after: "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376" decision: same-version ``` ## Compatibility -Existing one-shot and continuable catalog facts remain valid without the optional external field. The field identifies a child without a local Session and records pending or a terminal outcome. Pending means no terminal outcome is recorded, not that a process is live. Settlement appends a complete catalog fact with the same identity and creation time; the projection keeps one row per child. No new Session event type, full result payload, or Session format version is introduced. +Existing version-0 and version-1 one-shot and continuable catalog facts remain valid without the optional external field. Version-1 unknown-mode facts remain unchanged. The field identifies a child without a local Session and records pending or a terminal outcome. Pending means no terminal outcome is recorded, not that a process is live. Settlement appends a complete catalog fact with the same identity and creation time; the projection keeps one row per child. No new Session event type, full result payload, or Session format version is introduced. ## Verification -Focused catalog and external activation tests cover restoration, direct and descendant discovery, publication updates, cancellation, resource release, and output-independent catalog payload size. +Catalog projection, V3-to-V4 catalog completion and validation, and JSONL catalog migration tests passed: 124 tests passed and one skipped. Coverage includes mixed unknown-mode and external entries at both supported payload versions, external pending-to-terminal updates, and rejection of external fields on unknown or continuable entries. ## Dev Note diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json b/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json index c5dca03451..27682957da 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.schema.json @@ -6,7 +6,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a", + "digest": "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376", "schema": { "root": 0, "nodes": [ @@ -20,7 +20,7 @@ }, { "name": "ignorable", - "type": 16, + "type": 21, "optional": true }, { @@ -35,7 +35,7 @@ }, { "name": "type", - "type": 17, + "type": 22, "optional": false } ], @@ -45,7 +45,10 @@ "kind": "union", "types": [ 2, - 14 + 14, + 16, + 18, + 19 ] }, { @@ -135,6 +138,46 @@ "kind": "literal", "value": 0 }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "external", + "type": 5, + "optional": true + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, { "kind": "object", "properties": [ @@ -155,7 +198,7 @@ }, { "name": "mode", - "type": 15, + "type": 17, "optional": false }, { @@ -170,6 +213,72 @@ "kind": "literal", "value": "continuable" }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 17, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 20, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, { "kind": "literal", "value": true diff --git a/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md b/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md index d8a7c443f9..6d51ab84c3 100644 --- a/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md +++ b/docs/persistence-changes/2026-09-19-external-subagent-catalog.zh.md @@ -9,7 +9,7 @@ kind: persistence-change ## 概述 -为父级目录条目增加可选的外部执行结果状态。 +为父级目录中的一次性条目添加可选的外部执行结果。 ## 目录 @@ -27,20 +27,20 @@ id: 2026-09-19-external-subagent-catalog baseline: false changes: - root: "event:subagent/catalog" - previous: "2026-09-11-initial" - after: "e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a" + previous: "2026-09-20-unknown-child-catalog" + after: "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376" decision: same-version ``` ## 兼容性 -现有 one-shot 与 continuable 目录事实无需可选 external 字段仍然有效。该字段标识没有本地 Session 的子任务,并记录 pending 或终态。pending 表示尚无终态记录,不表示进程存活。结束时追加具有相同身份与创建时间的完整目录事实,投影为每个子任务保留一个条目。不新增 Session 事件类型、完整结果载荷或 Session 格式版本。 +已有 version-0 和 version-1 的一次性及可继续目录事实在没有可选 external 字段时仍然有效。Version-1 的未知模式事实保持不变。该字段标识没有本地 Session 的子任务,并记录 pending 或终态。Pending 表示尚未记录终态,不代表进程存活。结束时追加具有相同身份与创建时间的完整目录事实;投影为每个子任务保留一行。不引入新的 Session 事件类型、完整结果载荷或 Session 格式版本。 ## 验证 -聚焦的目录与外部激活测试覆盖恢复、直接与后代发现、发布更新、取消、资源释放,以及与输出大小无关的目录载荷。 +目录投影、V3 到 V4 目录补全与校验、JSONL 目录迁移测试通过:124 项通过,1 项跳过。覆盖两种受支持载荷版本中混合的未知模式和外部条目、外部 pending 到终态的更新,以及拒绝未知模式或可继续条目上的 external 字段。 ## 开发备注 diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml new file mode 100644 index 0000000000..60ad1d8f73 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml @@ -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 docs/persistence-changes/2026-09-20-unknown-child-catalog.md +2026-09-20-unknown-child-catalog.md: ba729661d60856ff948854641a1520339ab30210 +2026-09-20-unknown-child-catalog.zh.md: 468b4c9a3d3afab56ec8a9b03359016c9c0307e0 diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md new file mode 100644 index 0000000000..ba729661d6 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +English | [中文](2026-09-20-unknown-child-catalog.zh.md) + +## Summary + +Retain unreadable historical children in subagent/catalog with unknown mode. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" + decision: same-version +``` + + +## Compatibility + +Catalog payload v0 remains unchanged. Payload v1 adds unknown mode, and current readers accept v0 and v1. Complete facts still use v0; migration emits v1 for unknown children. Existing logs need no rewrite, and the Session header remains V4. Older readers reject v1. The classifier allows higher event payload versions only when all old payload alternatives remain unchanged; same-version widening and removal of old readers stay breaking. + + +## Verification + +Migration, restoration, projection, and Web regressions retain unknown membership and child-local errors. Classifier tests accept higher payload versions with preserved predecessors and reject same-version additions, invalid versions, changed or removed old alternatives, and header/surface changes. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json new file mode 100644 index 0000000000..14c4b254ad --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json @@ -0,0 +1,250 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:subagent/catalog", + "kind": "event", + "event": "subagent/catalog", + "surface": false, + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 9, + 11, + 12 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md new file mode 100644 index 0000000000..468b4c9a3d --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +[English](2026-09-20-unknown-child-catalog.md) | 中文 + +## 概述 + +在 subagent/catalog 中使用 unknown 模式保留不可读的历史子会话。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" + decision: same-version +``` + + +## 兼容性 + +Catalog 载荷 v0 保持不变。载荷 v1 增加 unknown 模式,当前读取器支持 v0 和 v1。完整事实继续使用 v0;迁移为未知子会话写入 v1。已有日志无需改写,Session header 保持 V4。旧读取器拒绝 v1。分类器仅在所有旧载荷分支保持不变时允许增加更高的事件载荷版本;同版本扩展和移除旧版读取支持仍属于破坏性变更。 + + +## 验证 + +迁移、恢复、投影和 Web 回归覆盖未知成员保留与子会话局部报错。分类器测试接受保留前代的更高载荷版本,并拒绝同版本新增、无效版本、旧分支修改或删除,以及 header/model-surface 变更。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/README.i18n.yaml b/docs/persistence-changes/README.i18n.yaml index 830e77b05e..ecf19d5bac 100644 --- a/docs/persistence-changes/README.i18n.yaml +++ b/docs/persistence-changes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/README.md -README.md: 3fd95e48548fdb9c1d04330c2925096d072850d1 -README.zh.md: f4ab3b3d22b1aa2f7f5923e84fd4effc339874db +README.md: 4f6cf773a939e0d584c637bcbfe27086677fdec7 +README.zh.md: 5754ff07c1011115c959379ab002ee3acc8b6f0e diff --git a/docs/persistence-changes/README.md b/docs/persistence-changes/README.md index 3fd95e4854..4f6cf773a9 100644 --- a/docs/persistence-changes/README.md +++ b/docs/persistence-changes/README.md @@ -53,15 +53,18 @@ Every detected structural change requires an acknowledgement. Record creation an | Add an optional event-body property, including its complete subtree | `same-version` | | Make a required event-body property optional | `same-version` | | Add an ordinary event type | `same-version` | +| Add a higher numeric `data.version` to an ordinary event while retaining every old payload alternative under the existing compatibility rules | `same-version` | | Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` | | Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` | | Change the Session header or event envelope | `version-bump` | A finalized checkpoint protects the accepted baseline without replacing these compatibility rules. At V4, optional additions, ordinary events, and qualified attribution additions can receive new same-version records. Breaking differences require a higher writer version and an acknowledgement containing its own header increase. The accepted V4 record cannot be updated to reuse its original 3→4 transition. +An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided every existing alternative remains readable under the compatibility rules above. Optional property additions and required-to-optional changes may accompany the new version; each retained alternative is compared separately. The reader must retain support for the old payloads; adding variants at an existing version, dropping old versions, and changing the Session header or envelope remain breaking. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition. + The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group. -A same-version explanation states why old records can omit the addition and why older readers can ignore it without changing replay. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). +A same-version explanation states how old records remain readable and how older readers handle new records. Optional additions explain why older readers can ignore them without changing replay; a new payload version records the older-reader rejection. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). When a reader rejects a JSON property by name, declare the property as optional `never` and mark it with argument-free `@persistenceReserved`. The extractor retains the forbidden field, so allowing a JSON value later requires an existing-field type change. Required or JSON-valued properties cannot carry this marker. Unmarked optional `never` and `undefined` properties retain their existing omission behavior. diff --git a/docs/persistence-changes/README.zh.md b/docs/persistence-changes/README.zh.md index f4ab3b3d22..5754ff07c1 100644 --- a/docs/persistence-changes/README.zh.md +++ b/docs/persistence-changes/README.zh.md @@ -53,15 +53,18 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc | 添加可选事件体属性,包括其完整子树 | `same-version` | | 将必选事件体属性改为可选 | `same-version` | | 添加普通事件类型 | `same-version` | +| 为普通事件增加更高的数字 `data.version`,并按现有兼容性规则保留所有旧载荷分支 | `same-version` | | 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` | | 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` | | 更改会话头或事件封装 | `version-bump` | 定稿检查点保护已接受基线,不替换这些兼容性规则。在 V4 中,可选新增、普通事件及符合条件的归属 kind 新增可以使用新的同版本记录。破坏性差异要求更高的写入器版本,以及包含自身头版本递增的确认记录。不能更新已接受 V4 记录以复用其原有 3→4 转换。 +普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支按上述兼容性规则保持可读。可选属性新增和必选改可选可以与新版本一同引入;每个保留分支均独立比较。读取器必须保留旧载荷支持;在已有版本中增加分支、移除旧版本,以及修改 Session header 或事件封装仍属于破坏性变更。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md) 记录了此类演进。 + 提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。 -同版本说明须解释旧记录为何可以缺少新增内容,以及旧读取器为何可以忽略它而不改变回放。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 +同版本说明须解释旧记录如何保持可读,以及旧读取器如何处理新记录。可选新增须说明旧读取器为何可以忽略它而不改变回放;新增载荷版本须记录旧读取器的拒绝行为。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 当读取器按属性名拒绝 JSON 字段时,将该属性声明为可选 `never`,并添加无参数的 `@persistenceReserved` 标记。提取器保留这一禁止字段,因此后续允许 JSON 值属于已有字段类型变更。必选属性或允许 JSON 值的属性不能携带该标记。未标记的可选 `never` 和 `undefined` 属性保留现有省略行为。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index f34e60f4e7..62def62200 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/historical-formats/README.md -README.md: f4e721bbef9d442021226c573280edc193792662 -README.zh.md: be4b7c86b68fa682e7f8275f85a2548f39f30df9 +README.md: 453137b6f196b5572973d793f6648220b2366571 +README.zh.md: a64ce4f1e030f35b831afc794625c0ec79ab394b diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index f4e721bbef..453137b6f1 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 585 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 589 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index be4b7c86b6..a64ce4f1e0 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 585 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 589 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index b1c2df2ad2..30205cf72a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14850,7 +14850,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a", + "digest": "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376", "schema": { "root": 0, "nodes": [ @@ -14864,7 +14864,7 @@ }, { "name": "ignorable", - "type": 16, + "type": 21, "optional": true }, { @@ -14879,7 +14879,7 @@ }, { "name": "type", - "type": 17, + "type": 22, "optional": false } ], @@ -14889,7 +14889,10 @@ "kind": "union", "types": [ 2, - 14 + 14, + 16, + 18, + 19 ] }, { @@ -14979,6 +14982,46 @@ "kind": "literal", "value": 0 }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "external", + "type": 5, + "optional": true + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, { "kind": "object", "properties": [ @@ -14999,7 +15042,7 @@ }, { "name": "mode", - "type": 15, + "type": 17, "optional": false }, { @@ -15014,6 +15057,72 @@ "kind": "literal", "value": "continuable" }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 17, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 20, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, { "kind": "literal", "value": true @@ -21828,6 +21937,20 @@ "names": [], "sources": [] }, + { + "digest": "05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf", "schema": { @@ -32589,6 +32712,293 @@ "names": [], "sources": [] }, + { + "digest": "289dc7167ce5942019b86cab1ccfacb72eaf7460ef3c45e3c23780bf8e030376", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 21, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 22, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 14, + 16, + 18, + 19 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "external", + "type": 5, + "optional": true + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "union", + "types": [ + 6, + 7, + 8, + 9, + 10, + 11 + ] + }, + { + "kind": "literal", + "value": "aborted" + }, + { + "kind": "literal", + "value": "completed" + }, + { + "kind": "literal", + "value": "error" + }, + { + "kind": "literal", + "value": "max-tokens" + }, + { + "kind": "literal", + "value": "pending" + }, + { + "kind": "literal", + "value": "refusal" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "external", + "type": 5, + "optional": true + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 17, + "optional": false + }, + { + "name": "version", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 17, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 20, + "optional": false + }, + { + "name": "version", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "290f32bfd81ff85a8095886a8066fae06e885942b63a8bcd6a8d66d328445808", "schema": { @@ -68060,6 +68470,63 @@ "names": [], "sources": [] }, + { + "digest": "a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80", "schema": { @@ -72700,6 +73167,103 @@ "names": [], "sources": [] }, + { + "digest": "bbf8af8c6b0edb0b150ea997c51bdc63e8def8428adb228bbc88af0577344391", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "external", + "type": 3, + "optional": true + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "union", + "types": [ + 4, + 5, + 6, + 7, + 8, + 9 + ] + }, + { + "kind": "literal", + "value": "aborted" + }, + { + "kind": "literal", + "value": "completed" + }, + { + "kind": "literal", + "value": "error" + }, + { + "kind": "literal", + "value": "max-tokens" + }, + { + "kind": "literal", + "value": "pending" + }, + { + "kind": "literal", + "value": "refusal" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825", "schema": { @@ -73389,6 +73953,258 @@ "names": [], "sources": [] }, + { + "digest": "c974738c089d11324eac001a8e4c032d278cebc0221efb9896ff72b922d82b7e", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 13, + 15, + 17, + 18 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "external", + "type": 4, + "optional": true + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 11, + "optional": false + }, + { + "name": "version", + "type": 12, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "union", + "types": [ + 5, + 6, + 7, + 8, + 9, + 10 + ] + }, + { + "kind": "literal", + "value": "aborted" + }, + { + "kind": "literal", + "value": "completed" + }, + { + "kind": "literal", + "value": "error" + }, + { + "kind": "literal", + "value": "max-tokens" + }, + { + "kind": "literal", + "value": "pending" + }, + { + "kind": "literal", + "value": "refusal" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "external", + "type": 4, + "optional": true + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 11, + "optional": false + }, + { + "name": "version", + "type": 14, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 16, + "optional": false + }, + { + "name": "version", + "type": 12, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 16, + "optional": false + }, + { + "name": "version", + "type": 14, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 19, + "optional": false + }, + { + "name": "version", + "type": 12, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:28" + ] + }, { "digest": "c99e20441bf15341e9db54385e558e5c03d21dff2eb1ac08ccbf47c2d8747954", "schema": { @@ -74166,149 +74982,6 @@ "names": [], "sources": [] }, - { - "digest": "d1506e1f9d875f1fec75d1de69dd0023d6c531d5008fb72d5e13ba16a6a62416", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 13 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "external", - "type": 4, - "optional": true - }, - { - "name": "label", - "type": 3, - "optional": true - }, - { - "name": "mode", - "type": 11, - "optional": false - }, - { - "name": "version", - "type": 12, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "union", - "types": [ - 5, - 6, - 7, - 8, - 9, - 10 - ] - }, - { - "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 2, - "optional": false - }, - { - "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", - "type": 14, - "optional": false - }, - { - "name": "version", - "type": 12, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "continuable" - } - ] - }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:24" - ] - }, { "digest": "d1f27cf1650e5f59d5d6e95ce6dfd92b31757af076a8ab6418a70b6dbdd53621", "schema": { @@ -81400,6 +82073,63 @@ "packages/schedule/schedule/src/types.ts:105" ] }, + { + "digest": "e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa", "schema": { @@ -82546,184 +83276,6 @@ "names": [], "sources": [] }, - { - "digest": "e6c1b140467c8a4a41ca50676e4213d238f082c3b11b9f998fc9fd4ac21e929a", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 16, - "optional": true - }, - { - "name": "seq", - "type": 3, - "optional": false - }, - { - "name": "time", - "type": 3, - "optional": false - }, - { - "name": "type", - "type": 17, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 14 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "external", - "type": 5, - "optional": true - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 12, - "optional": false - }, - { - "name": "version", - "type": 13, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "union", - "types": [ - 6, - 7, - 8, - 9, - 10, - 11 - ] - }, - { - "kind": "literal", - "value": "aborted" - }, - { - "kind": "literal", - "value": "completed" - }, - { - "kind": "literal", - "value": "error" - }, - { - "kind": "literal", - "value": "max-tokens" - }, - { - "kind": "literal", - "value": "pending" - }, - { - "kind": "literal", - "value": "refusal" - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 15, - "optional": false - }, - { - "name": "version", - "type": 13, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "e72269f931559e901e5e8fe1cfcbd0669fb795f43cc038ff39fba62f60403c33", "schema": { diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index a1164e5891..30b50d6a0a 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: 9ad6bd63ed63c5c59dbe29cd1229a6412ad22de2 -sidebar-right.zh.md: d0a967f400ea80ab696e24e1b4f46d7d8664eff8 +sidebar-right.md: 27e5219aec8997cf0d168ac9ea2665baaf943906 +sidebar-right.zh.md: 63f2c1081009778e191789038ffd1923312fc55b diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 9ad6bd63ed..27e5219aec 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -136,7 +136,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list` and watched by expanded directory, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). -- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). +- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Chat's [link preference](../../packages/client/ui-chat/README.md) controls whether Assistant Markdown opens HTTP(S) links here. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). - **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index d0a967f400..63f2c10810 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -136,7 +136,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,按展开目录建立监听,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 -- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 +- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Chat 的[链接偏好](../../packages/client/ui-chat/README.zh.md)决定 Assistant Markdown 是否在此打开 HTTP(S) 链接。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 - **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index c8a1d3a8a3..08ae8cfcf1 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 9f749c3f335c1d43ac1ef90cac5fecc96f8a2236 -subagent.zh.md: 41ad281012aec5ddedd2b6bd025245da320a4fe4 +subagent.md: b6113686c677213bd052907b72eb54aa6e690489 +subagent.zh.md: 7a5bd28e0ab8a3a4384e14959145e4565d9d0d4b diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 9f749c3f33..b6113686c6 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -6,7 +6,7 @@ The unified activation API manages local conversations and external executions. ## Capabilities and activation requests -Every backend uses startActivation. Capability flags validate request options; prepareContinuable selects the local driver, while start selects one external execution. The model-facing tool uses parent delivery. Workflow and code mode consumers can await caller results without injecting another parent message. +Every backend uses startActivation. Capability flags validate request options; prepareContinuable selects the local driver, while start selects one external execution. The model-facing tool uses parent delivery. Workflow and code mode consumers can await caller results without injecting another parent message. Historical children with unavailable descriptors remain discoverable as `mode: 'unknown'` without granting continuation capabilities; the [package README](../../packages/subagent/subagent/README.md) defines catalog persistence. ```ts type-equiv /** diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 41ad281012..7a5bd28e0a 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -6,7 +6,7 @@ ## 能力与 activation 请求 -所有后端都使用 startActivation。能力标记校验请求选项;prepareContinuable 选择本地 driver,start 选择一次外部执行。面向模型的工具采用 parent 回传。Workflow 与 code mode 消费者可以等待 caller 结果,不额外向父代理注入消息。 +所有后端都使用 startActivation。能力标记校验请求选项;prepareContinuable 选择本地 driver,start 选择一次外部执行。面向模型的工具采用 parent 回传。Workflow 与 code mode 消费者可以等待 caller 结果,不额外向父代理注入消息。历史子会话的 descriptor 不可用时,以 `mode: 'unknown'` 保持可发现性,但不授予继续执行能力;[包 README](../../packages/subagent/subagent/README.zh.md) 定义目录持久化语义。 ```ts type-equiv /** diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 452a429302..a77a7ed049 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md -llm-adapter.md: 7e30847cdf265bca8f55aac40f73686101ec185d -llm-adapter.zh.md: 22a3c54f45088a41362c1a8e903d2441ed4fc93b +llm-adapter.md: 32e412b10ade376e88d23be4de8cb2de976706f0 +llm-adapter.zh.md: bfa9842ce583f0486aff6269ad78c1f465f43671 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 7e30847cdf..32e412b10a 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -146,7 +146,7 @@ The first argument lists provider routes handled by the adapter. `GenerateOption The repository contains complete implementations: -- `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format +- `packages/llm/llm-deepseek/` — DeepSeek adapter using the Messages API - `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format Compare the two shipped adapters to see the same harness contract implemented over different provider SDKs. diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 22a3c54f45..bfa9842ce5 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -146,7 +146,7 @@ ctx.llm.registerAdapter(['my-provider'], adapter) 仓库中包含以下两个完整实现: -- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) +- `packages/llm/llm-deepseek/` — 使用 Messages API 的 DeepSeek 适配器 - `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) 对比这两个已交付的适配器,可以看到同一套 harness 契约如何在不同提供方 SDK 之上实现。 diff --git a/package.json b/package.json index 4d36479376..eb4b2b92c9 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "demo:ptc": "node scripts/demo-ptc.mjs", "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", + "start:web": "node --import tsx/esm apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 32ffc5e1cd..e63c0b2a46 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 56d0f17b815a4616707d33bfd84b77446125f742 -README.zh.md: 9d754a5a61da548e1fd6dd10d1332582bdd3d126 +README.md: 49f38e5af8f0939dd8f1ccafcd7928b10954ca33 +README.zh.md: 57112fc5ce88567ecfe415ed7ee4dd5ac9d34224 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 56d0f17b81..49f38e5af8 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -63,7 +63,7 @@ Attachment authorization reads declared content fields of built-in Session event `sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures. -References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. +References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 9d754a5a61..57112fc5ce 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -63,7 +63,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 `sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。 -引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 +引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 08d935233c..ec880192f0 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -805,6 +805,7 @@ export class Session implements SessionFace { } private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent return { sessionId: this.sessionId, pendingSubmissions: this.pendingSubmissions, @@ -812,7 +813,9 @@ export class Session implements SessionFace { subagent: this.address === undefined ? null : { - address: this.address, + address: this.address.mode === 'unknown' && identity != null + ? { ...this.address, mode: identity.mode } + : this.address, ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index a22badc1f1..069ee3bf32 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -364,7 +364,7 @@ function validateAddress( reason: 'unsupported', }) } - if (identity.mode !== address.mode) { + if (address.mode !== 'unknown' && identity.mode !== address.mode) { throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 37294a6dbb..728df9e55b 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -407,7 +407,7 @@ export type SessionAddress = readonly kind: 'subagent' readonly parentSessionId: SessionId readonly childSessionId: SessionId - readonly mode: 'one-shot' | 'continuable' + readonly mode: 'one-shot' | 'continuable' | 'unknown' } /** One non-activating Session projection read. */ diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index aed5009fdd..55d182110a 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -817,6 +817,25 @@ describe('subagent catalogs', () => { }) }) + it('resolves an unknown browsing mode from the child projection without changing parent membership', async ({ mock, start }) => { + mock.load(sessionWorld) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + const entries = [{ id: S2, createdAt: 1, mode: 'unknown' as const }] + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries }) + const address = manager.subagentAddress(S2)! + expect(address).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unknown' }) + manager.resolveTarget(address) + const child = manager.get(S2) + expect(child.getSnapshot().subagent?.address.mode).toBe('unknown') + manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'subagent', seq: 0, + value: { seq: SessionSeq(0), mode: 'continuable', label: 'repaired child' } }) + await child.open() + expect(child.getSnapshot().subagent?.address).toEqual({ ...address, mode: 'continuable' }) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries) + await manager.dispose() + }) + it('publishes pushed membership without a catalog request or an instantiated parent', ({ mock, remote }) => { const manager = makeManager(mock, remote) manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true })) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 04b43972e9..341aaf4e86 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -451,6 +451,16 @@ describe('SessionHistoryController', () => { }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).resolves.toMatchObject({ + records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], + }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'subagent', diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index aed223888a..98b11724ed 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 8439fb9ec1173ba24f9775ac476e64d23534a594 -README.zh.md: 0bb8c9819dacabed01905f6b7aff8c770ea3caae +README.md: 6f24c46485abe3761994ef7aacbea90840e01bfb +README.zh.md: b3a8aaa2746848bcc347291dad27f015e8a004e3 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 8439fb9ec1..6f24c46485 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -68,7 +68,7 @@ Before you boot, you can print the exact configuration the app will mount: the d ### Reading plugin display metadata -Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. +Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads `package.json.icon` as an image data URL, even when locale text is complete; an icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. ### Startup and reload failures diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 0bb8c9819d..b3a8aaa274 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -68,7 +68,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head ### 读取插件展示元信息 -使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 +使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会将 `package.json.icon` 加载为图片 data URL;图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 ### 启动与重载失败 diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts index 434b233d1e..131082ce8a 100644 --- a/packages/boot/app-boot/src/package-meta.ts +++ b/packages/boot/app-boot/src/package-meta.ts @@ -1,7 +1,7 @@ -/** Read plugin display metadata through exported locale resources without evaluating plugin code. */ +/** Read plugin display text and icons through exported resources without evaluating plugin code. */ -import { readdirSync, readFileSync } from 'node:fs' -import { dirname } from 'node:path' +import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { dirname, extname, isAbsolute, relative, resolve, sep, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader' import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' @@ -11,6 +11,35 @@ const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u type DisplayFields = Record<'title' | 'description', string | undefined> +/** Maximum raw icon bytes admitted to plugin metadata. */ +const MAX_ICON_BYTES = 256 * 1024 +const ICON_MEDIA_TYPES = new Map([ + ['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], +]) + +function iconOf(value: unknown, manifestPath: string): string | undefined { + const icon = textOf(value, `${manifestPath}: icon`) + if (icon === undefined) return undefined + if (isAbsolute(icon) || win32.isAbsolute(icon) || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(icon)) { + throw new Error(`${manifestPath}: icon must be a relative file path`) + } + const mediaType = ICON_MEDIA_TYPES.get(extname(icon).toLowerCase()) + if (mediaType === undefined) throw new Error(`${manifestPath}: icon must be SVG, PNG, JPEG, or WebP`) + const directory = realpathSync(dirname(manifestPath)) + const file = realpathSync(resolve(directory, icon)) + const local = relative(directory, file) + if (local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) { + throw new Error(`${manifestPath}: icon must remain inside its manifest directory`) + } + const stat = statSync(file) + if (!stat.isFile()) throw new Error(`${manifestPath}: icon must be a regular file`) + if (stat.size > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`) + const bytes = readFileSync(file) + if (bytes.length > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`) + return `data:${mediaType};base64,${bytes.toString('base64')}` +} + /** * Resolve a plugin resource through the active Node ESM resolver without evaluating it. * @param specifier - complete resource module specifier, including its locale filename. @@ -104,7 +133,9 @@ function localizedText( } /** - * Read meta.title and meta.description from a plugin's exported locale JSON files. + * Read localized display text and the icon declared in a plugin's exported package.json. + * Icons are manifest-relative SVG, PNG, JPEG, or WebP files of at most 256 KiB, + * contained in the manifest directory after realpath resolution. Icon failures retain display text. * Non-package specifiers are skipped without invoking the resource resolver. * Language files share the directory containing the resolved English resource; * each file is resolved through the complete plugin specifier before reading. @@ -112,25 +143,29 @@ function localizedText( * maps retain an English fallback, ultimately the full module specifier for titles and empty for descriptions. * @param specifier - configured plugin module name, including any package subpath. * @param parentURL - owning Loader tree's module-resolution base. - * @returns translated or fallback fields, a diagnostic, or undefined for non-package specifiers or absent display text. + * @returns display fields and any icon diagnostic, or undefined for non-package specifiers or absent metadata. */ export function readPluginMeta(specifier: string, parentURL: string): PluginLocalizedMeta | undefined { if (barePackageName(specifier) === undefined) return undefined try { const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL) const dictionaries = englishPath === undefined ? new Map() : dictionariesOf(englishPath, specifier, parentURL) - const english = dictionaries.get('en') - const manifestPath = english?.title === undefined || english.description === undefined - ? optionalResourcePath(`${specifier}/package.json`, parentURL) - : undefined + const manifestPath = optionalResourcePath(`${specifier}/package.json`, parentURL) const manifest = manifestPath === undefined ? undefined : readObject(manifestPath) const title = localizedText('title', dictionaries, fallbackText(manifest?.name), specifier) const description = localizedText('description', dictionaries, fallbackText(manifest?.description), '') - if (title === undefined && description === undefined) return undefined - return { + const text = { ...title === undefined ? {} : { title }, ...description === undefined ? {} : { description }, } + let icon: string | undefined + try { + icon = manifestPath === undefined ? undefined : iconOf(manifest?.icon, manifestPath) + } catch (error) { + return { ...text, error: `Plugin metadata for ${specifier}: ${String(error)}` } + } + if (title === undefined && description === undefined && icon === undefined) return undefined + return { ...text, ...icon === undefined ? {} : { icon } } } catch (error) { return { error: `Plugin metadata for ${specifier}: ${String(error)}` } } diff --git a/packages/boot/app-boot/tests/package-meta.spec.ts b/packages/boot/app-boot/tests/package-meta.spec.ts index 07b9af374b..73484f7db9 100644 --- a/packages/boot/app-boot/tests/package-meta.spec.ts +++ b/packages/boot/app-boot/tests/package-meta.spec.ts @@ -1,6 +1,6 @@ /** Plugin locale resources resolve independently of entry execution and package display fields. */ -import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -161,15 +161,122 @@ describe('plugin locale display metadata', () => { expect(readPluginMeta('localized', parentURL)?.error).toContain('meta.title must be a non-empty string') }) - it('does not inspect package fallback when both English fields are supplied', () => { - manifest({ './locale/*.json': './locale/*.json', './package.json': './fallback.json' }) - file(join(dir, 'fallback.json'), '{') + it('reads the manifest icon even when both English fields are supplied', () => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon: './art/team.svg' }) + file(join(dir, 'art', 'team.svg'), '') dictionary('en', { meta: { title: 'Plugin', description: 'Locale introduction' } }) expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'Plugin' }, description: { en: 'Locale introduction' }, + icon: `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`, }) }) + it.each([ + ['svg', 'image/svg+xml'], ['png', 'image/png'], ['jpg', 'image/jpeg'], + ['jpeg', 'image/jpeg'], ['webp', 'image/webp'], ['SVG', 'image/svg+xml'], + ])('encodes a package-local %s icon without executing the plugin', (extension, mediaType) => { + manifest({ '.': './index.js', './package.json': './package.json' }, { icon: `./icon.${extension}` }) + const bytes = Buffer.from([0, 1, 127, 128, 255]) + writeFileSync(join(dir, `icon.${extension}`), bytes) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: 'localized', icon: `data:${mediaType};base64,${bytes.toString('base64')}`, + }) + }) + + it('reads an icon-only subexport relative to its own manifest without inheriting the package icon', () => { + manifest({ './package.json': './package.json', './search/package.json': './search/manifest.json' }, { icon: './root.png' }) + file(join(dir, 'root.png'), 'root') + file(join(dir, 'search', 'manifest.json'), JSON.stringify({ icon: './icon.webp' })) + file(join(dir, 'search', 'icon.webp'), 'search') + expect(readPluginMeta('localized/search', parentURL)).toEqual({ + icon: `data:image/webp;base64,${Buffer.from('search').toString('base64')}`, + }) + expect(readPluginMeta('localized/review', parentURL)).toBeUndefined() + }) + + it.each([null, false, 1, {}, [], '', ' '])('retains localized text while rejecting malformed icon %j', (icon) => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon }) + dictionary('en', { meta: { title: 'Plugin', description: 'Introduction' } }) + dictionary('zh', { meta: { title: '插件' } }) + const { error, ...meta } = readPluginMeta('localized', parentURL)! + expect(meta).toEqual({ title: { en: 'Plugin', zh: '插件' }, description: { en: 'Introduction' } }) + expect(error).toContain('icon must be a non-empty string') + }) + + it.each(['/tmp/icon.svg', 'C:\\icon.png', 'C:icon.png', '\\\\server\\share\\icon.png', 'https://example.test/icon.svg', 'data:image/svg+xml,'])('rejects non-relative icon %s', (icon) => { + manifest({ './package.json': './package.json' }, { icon, description: 'Introduction' }) + const { error, ...meta } = readPluginMeta('localized', parentURL)! + expect(meta).toEqual({ title: 'localized', description: 'Introduction' }) + expect(error).toContain('icon must be a relative file path') + }) + + it.each(['icon.gif', 'icon.html', 'icon', 'icon.svg?query'])('rejects unsupported icon file %s', (icon) => { + manifest({ './package.json': './package.json' }, { icon }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be SVG, PNG, JPEG, or WebP') + }) + + it('reports missing files, directories, and paths outside the manifest directory', () => { + manifest({ './package.json': './package.json' }, { icon: './missing.svg' }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('missing.svg') + mkdirSync(join(dir, 'missing.svg')) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be a regular file') + manifest({ './package.json': './package.json' }, { icon: '../localized-other/icon.svg' }) + file(join(root, 'node_modules', 'localized-other', 'icon.svg'), 'outside') + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory') + }) + + it('accepts an icon at the byte limit and rejects one byte more', () => { + manifest({ './package.json': './package.json' }, { icon: './icon.png' }) + const bytes = Buffer.alloc(256 * 1024) + writeFileSync(join(dir, 'icon.png'), bytes) + expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/png;base64,${bytes.toString('base64')}`) + writeFileSync(join(dir, 'icon.png'), Buffer.alloc(bytes.length + 1)) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB') + }) + + it('rejects an icon that grows beyond the limit after its size check', () => { + manifest({ './package.json': './package.json' }, { icon: './icon.png' }) + const icon = join(dir, 'icon.png') + file(icon, 'small') + const original = fs.statSync + const stat = vi.spyOn(fs, 'statSync') + try { + stat.mockImplementation(new Proxy(original, { + apply(target, receiver: unknown, args: unknown[]): unknown { + const result: unknown = Reflect.apply(target, receiver, args) + if (String(args[0]).endsWith(join('localized', 'icon.png'))) writeFileSync(icon, Buffer.alloc(256 * 1024 + 1)) + return result + }, + })) + syncBuiltinESMExports() + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB') + } finally { + stat.mockRestore() + syncBuiltinESMExports() + } + }) + + it('rejects icons reached through an external directory link and accepts internal links', () => { + manifest({ './package.json': './package.json' }, { icon: './art/icon.svg' }) + const outside = join(root, 'outside') + file(join(outside, 'icon.svg'), 'outside') + const link = join(dir, 'art') + symlinkSync(outside, link, 'junction') + try { + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory') + } finally { + unlinkSync(link) + } + const inside = join(dir, 'images') + file(join(inside, 'icon.svg'), 'inside') + symlinkSync(inside, link, 'junction') + try { + expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/svg+xml;base64,${Buffer.from('inside').toString('base64')}`) + } finally { + unlinkSync(link) + } + }) + it('keeps separate plugin exports independent even when their JavaScript entries share a directory', () => { manifest({ './search': './lib/search.js', './review': './lib/review.js', @@ -285,8 +392,9 @@ describe('plugin locale display metadata', () => { }) const specifier = 'localized/locale/en.json' expect(resolveSync.mock.calls).toEqual(version === 'v1' - ? [[specifier, parentURL, {}], [specifier, parentURL, {}]] - : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }]]) + ? [[specifier, parentURL, {}], [specifier, parentURL, {}], ['localized/package.json', parentURL, {}]] + : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }], + [parentURL, { specifier: 'localized/package.json', attributes: {} }]]) }) it('rejects case-equivalent language names in a directory listing', () => { diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 28c001b8ce..e5cf4d8399 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -446,7 +446,7 @@ # preset; products with a stricter network policy override tool-web. DeepSeek # search resolves the same DEEPSEEK_API_KEY # credential the Models page manages for chat, at each search; its Messages - # endpoint is separate from the chat-completions endpoint, so it takes its own + # endpoint is configured independently from the model route, so it takes its own # base-URL override. Anonymous fetch accepts only public HTTP(S) destinations, # resolves and validates every destination, and pins every actual connection. # Search is a full auxiliary model request with server-side retrieval, so this diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 705ff4a9a4..ad5bbccf32 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 00f523098ada6646a2cf22ce57ca5fef6c6d9afb -README.zh.md: 2a3ea0d7d480ab2a29aa1d4adcb01836a9b70007 +README.md: 8fea1287610a0a71f2a351a1b8e00af87b20ba44 +README.zh.md: 603108991cd74ba379b3b9d8de9a2ff11bf7e101 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 00f523098a..8fea128761 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -36,9 +36,9 @@ dsh --profile web --no-open --port 8080 After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the same directory without the token. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself. -**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#choose-a-protocol) defaults to Messages; set `protocol: chat-completions` in Cordis YAML to select Chat Completions. Web has no protocol selector. +**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API. -Saved model selections override the composition default. Both protocols share `deepseek-official` and `llm-deepseek` settings, so switching preserves model selections and credential references. Endpoint overrides retain their values; the settings card lets users supply a compatible API address. +Saved model selections override the composition default. The settings card accepts a Messages-compatible API address and a credential reference. ### Configuration diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 2a3ea0d7d4..603108991c 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -36,9 +36,9 @@ dsh --profile web --no-open --port 8080 启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的同一目录。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。 -**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#choose-a-protocol)默认使用 Messages;在 Cordis YAML 中设置 `protocol: chat-completions` 可选择 Chat Completions。Web 不提供协议选择器。 +**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。 -已保存的模型选择优先于组合默认值。两种协议共用 `deepseek-official` 与 `llm-deepseek` 设置,因此切换协议不改变模型选择或复制凭据。端点覆盖保持原值;设置卡片允许用户填写兼容的 API 地址。 +已保存的模型选择覆盖组合默认值。设置卡接受兼容 Messages 的 API 地址与凭据引用。 ### 配置 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index b4a6d0ff06..3a18b07b71 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -233,9 +233,10 @@ - id: ui-sidebar-documentpreview name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' - # Sandboxed HTTP(S) Browser tabs, including loopback targets. + # Web profiles opt in; Desktop retains sandboxed HTTP(S) Browser tabs. - id: ui-sidebar-browser name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'" - id: ui-sidebar-terminal name: '@deepseek-ai/dsh-client-ui-sidebar-terminal' diff --git a/packages/bundle/web-app/tests/browser-defaults.spec.ts b/packages/bundle/web-app/tests/browser-defaults.spec.ts new file mode 100644 index 0000000000..73ee9b1a82 --- /dev/null +++ b/packages/bundle/web-app/tests/browser-defaults.spec.ts @@ -0,0 +1,41 @@ +/** Profile defaults and explicit overrides for the shipped Browser entry. */ +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' +import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import * as Browser from '@deepseek-ai/dsh-client-ui-sidebar-browser' +import { expect, it, onTestFinished } from 'vitest' + +it.each([ + { profile: 'web', override: undefined, disabled: true }, + { profile: 'custom-web', override: undefined, disabled: true }, + { profile: undefined, override: undefined, disabled: true }, + { profile: 'desktop', override: undefined, disabled: false }, + { profile: 'web', override: false, disabled: false }, + { profile: 'desktop', override: true, disabled: true }, +])('applies Browser availability for $profile with override $override', async ({ profile, override, disabled }) => { + const rows = loadOverlayPatches('browser-defaults', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) + .flatMap(patch => patch.insert ?? []).filter(row => row.id === 'ui-sidebar-browser') + expect(rows).toHaveLength(1) + expect(rows[0]!.name).toBe('@deepseek-ai/dsh-client-ui-sidebar-browser') + const configured = applyEntryPatches(rows, override === undefined ? [] : [{ id: 'ui-sidebar-browser', disabled: override }], + (message) => { throw new Error(message) }) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + if (profile !== undefined) { + ctx.provide('profileContext', { + name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json', + cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined, + }) + } + ctx.baseUrl = 'file:///' + await ctx.plugin(Loader).await() + // Keep the real companion in the source-plane loader without native package resolution. + ctx.loader.builtins.browser = Browser + await ctx.loader.root.update([{ ...configured[0]!, name: 'cordis:browser' }]) + await ctx.loader.await() + const entry = ctx.loader.resolve('ui-sidebar-browser') + expect(entry.disabled).toBe(disabled) + expect(entry.fiber !== undefined).toBe(!disabled) +}) diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index 07ad3d545d..f6861208ea 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/hmr/README.md -README.md: e459da10332ffdb3af422b75f639ce25df512487 -README.zh.md: 54e2f3af63472dedbfc75c5b4400b5d93e14f311 +README.md: 5dae1463ceed315c3409bf97de80649f10e83fa8 +README.zh.md: a77befcf63cd78be66b7e5b636d86367af6fdcdf diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index e459da1033..5dae1463ce 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -29,7 +29,7 @@ The shipped Web composition mounts this transport for live plugin changes. Durin ### Starting the reload chain -Run `pnpm run dev:web` (or a watch process using the shared Client tsdown preset) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning. +Run `pnpm run dev:web`, which starts the host and the rebuild watchers together (`--no-serve` attaches only the watchers to a host started elsewhere, as does any watch process using the shared Client tsdown preset); rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning. ### What a reload does diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md index 54e2f3af63..a77befcf63 100644 --- a/packages/client/hmr/README.zh.md +++ b/packages/client/hmr/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 启动重载链路 -对同一个宿主运行 `pnpm run dev:web`(或使用共享 Client tsdown 预设的 watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。 +运行 `pnpm run dev:web`,它会同时启动宿主与重建 watcher(`--no-serve` 则只把 watcher 接到别处启动的宿主上,使用共享 Client tsdown 预设的任何 watch 进程亦然);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。 ### 一次重载做什么 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index c42928d650..7d9e60da65 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: b916802917bfdd406fb333174371507d466b8e19 -README.zh.md: 01de4d633cb1501a03200aa96f72e89622d0a7d8 +README.md: eea8eeda01c3d0bd30669cdf3743c64be0d76fbe +README.zh.md: 58fef8e8c47ae5f67c8ecbf614a36bbfbf2e20b3 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b916802917..eea8eeda01 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -32,7 +32,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. -HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. +Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: Built-in browser (default) opens a new right-Sidebar Browser tab, while New browser tab opens an external tab. The setting is shown only while the built-in browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. ## System prompt row diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 01de4d633c..58fef8e8c4 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 -Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +设置 → 通用设置 → 聊天链接打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「内置浏览器」(默认)打开新的右侧 Sidebar Browser tab,「浏览器新标签页」打开外部标签页。该设置项仅在内置浏览器可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 ## 系统提示词行 diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index dbf9014320..021a2b0616 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -26,16 +26,25 @@ export type PerformanceUsageMode = typeof PERFORMANCE_USAGE_MODES[number] /** Preserve detailed accounting for users without an explicit preference. */ export const DEFAULT_PERFORMANCE_USAGE: PerformanceUsageMode = 'detailed' +/** Destinations for ordinary clicks on Chat HTTP(S) links. */ +export type LinkOpening = 'sidebar' | 'new-tab' + +/** Preserve the built-in browser for users without an explicit preference. */ +export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar' + /** Durable Chat section shared by the Host schema and browser scope. */ export interface ChatSettings { /** Presentation mode for completed Turn process content. */ transcriptView: TranscriptViewMode /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode + /** Default destination for Chat HTTP(S) links. */ + linkOpening: LinkOpening } /** Durable Chat schema; also the wire envelope the browser scope validates against. */ export const ChatSettingsSchema: z = z.object({ + linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), }) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 21a63ab782..079a5e2512 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' -import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client' @@ -34,7 +34,8 @@ import { en, NS, zh } from './locale.ts' import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx' import { createChatStore } from './stores.ts' import { TranscriptViewPolicy } from './transcript-view.ts' -import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts' +import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, type ChatSettings } from '../chat-settings.ts' +import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpeningRow.tsx' import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx' import { PerformanceUsagePolicy } from './performance-usage.ts' import { useTurnDataValue } from './chat/use-turn-data.ts' @@ -82,6 +83,33 @@ export function apply(ctx: Context): void { const chatStore = createChatStore() const chatScrollPositions = new Map() const chatSettings = ctx.settingsScope.bind({ namespace: CHAT_SETTINGS_NAMESPACE }) + const linkOpening = createSnapshotStore(chatSettings.getSnapshot().value?.linkOpening ?? DEFAULT_LINK_OPENING) + ctx.effect(() => chatSettings.subscribe(() => { + const accepted = chatSettings.getSnapshot().value?.linkOpening + if (accepted !== undefined) linkOpening.set(accepted) + })) + ctx.inject(['sidebarRightTabs'], (scope) => { + const tabs = scope.sidebarRightTabs + const browserAvailable: ObservableSnapshot = { + getSnapshot: () => tabs.get('browser') !== undefined, + subscribe: listener => tabs.subscribe(listener), + } + scope.slots.inject('settings.general.item', () => scope.slots.register({ + name: 'settings.general.item', + id: 'link-opening', + order: 14, + locale: NS, + inject: (): LinkOpeningRowInjected => ({ + hooks: { linkOpening, browserAvailable }, + setLinkOpening: (destination) => { + linkOpening.set(destination) + void chatSettings.set('linkOpening', destination).catch((_error: unknown) => { + // The local choice remains usable when persistence is unavailable. + }) + }, + }), + }, LinkOpeningRow)) + }) const transcriptView = new TranscriptViewPolicy(chatSettings) const performancePolicy = new PerformanceUsagePolicy(chatSettings) const performanceUsage = performancePolicy.mode @@ -158,7 +186,7 @@ export function apply(ctx: Context): void { ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` }) }, openExternalLink: (url) => { - if (ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { + if (linkOpening.getSnapshot() === 'sidebar' && ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { ctx.sidebarRight.openTab('browser', { params: { url } }) } else { window.open(url, '_blank', 'noopener,noreferrer') diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index d5b007b7cf..5e811b7963 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -150,7 +150,7 @@ export interface ChatViewInjected { } /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void - /** Open one HTTP(S) message link in a Sidebar Browser tab. */ + /** Open one HTTP(S) message link at the selected destination, using an external tab if Sidebar Browser is unavailable. */ openExternalLink: (url: string) => void openFile: (path: string, options?: OpenFileOptions) => Promise loadOlder: () => void diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 344fef13d0..51d3adca00 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -31,6 +31,10 @@ export const zh = { 'settings.performance.description': '选择性能与用量信息展示的详细程度', 'settings.performance.compact': '简洁', 'settings.performance.detailed': '详细', + 'settings.links.title': '聊天链接打开方式', + 'settings.links.description': '选择聊天中 HTTP(S) 链接的打开位置', + 'settings.links.sidebar': '内置浏览器', + 'settings.links.newTab': '浏览器新标签页', 'settings.transcript.title': '对话显示', 'settings.transcript.description': '控制已完成轮次的过程内容', 'settings.transcript.normal': '标准', @@ -141,6 +145,10 @@ export const en = { 'settings.performance.description': 'Choose how much performance and usage information to show', 'settings.performance.compact': 'Compact', 'settings.performance.detailed': 'Detailed', + 'settings.links.title': 'Open chat links in', + 'settings.links.description': 'Choose where HTTP(S) links in chat open', + 'settings.links.sidebar': 'Built-in browser', + 'settings.links.newTab': 'New browser tab', 'settings.transcript.title': 'Conversation display', 'settings.transcript.description': 'Controls process content in completed turns', 'settings.transcript.normal': 'Normal', diff --git a/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx b/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx new file mode 100644 index 0000000000..b17a3e4929 --- /dev/null +++ b/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx @@ -0,0 +1,47 @@ +/** General Settings row for Chat HTTP(S) link destinations. */ +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { LinkOpening } from '../../chat-settings.ts' +import { PreferenceRow } from './PreferenceRow.tsx' + +/** Registration-side link-opening preference. */ +export interface LinkOpeningRowInjected { + hooks: { + /** Current destination, bound as useLinkOpening. */ + linkOpening: ObservableSnapshot + /** Whether the built-in browser is registered, bound as useBrowserAvailable. */ + browserAvailable: ObservableSnapshot + } + /** Change the default destination for Chat HTTP(S) links. */ + setLinkOpening: (destination: LinkOpening) => void +} + +/** Full Settings-row props. */ +export type LinkOpeningRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'chat'> + & InjectFace + +/** + * Render the link-opening destination selector. + * @param props - Composed Settings slot props. + * @returns The preference row. + */ +export function LinkOpeningRow({ useLinkOpening, useBrowserAvailable, setLinkOpening, t }: LinkOpeningRowProps) { + const destination = useLinkOpening(value => value) + const browserAvailable = useBrowserAvailable(value => value) + if (!browserAvailable) return null + return ( + { setLinkOpening(value as LinkOpening) }} + /> + ) +} diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 7977740640..dff3bbe376 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -19,6 +19,8 @@ import { import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createChatStore } from '../src/client/stores.ts' +import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' +import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx' usePinnedBrowserLanguages('zh-CN') @@ -47,9 +49,16 @@ function sessionFakeFor() { } satisfies SessionBehaviorOverrides } -async function bench() { +async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) + const chatSettings = stubSettingsScope() + if (initialSettings !== undefined) chatSettings.publish({ value: initialSettings }) + runtime.ctx.provide('settingsScope', { + developerTools: { enabled: createSnapshotStore(true) }, + bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE + ? chatSettings.scope + : stubSettingsScope().scope, + } as never) const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() } runtime.ctx.provide('layout', layout as never) const sidebarRight = { @@ -57,11 +66,13 @@ async function bench() { openTab: vi.fn<(kind: string, options?: unknown) => void>(), } runtime.ctx.provide('sidebarRight', sidebarRight as never) + const browserAvailable = createSnapshotStore(true) const sidebarRightTabs = { - get: vi.fn<(kind: string) => object | undefined>(() => ({})), + get: vi.fn<(kind: string) => object | undefined>(() => browserAvailable.getSnapshot() ? {} : undefined), + subscribe: (listener: () => void) => browserAvailable.subscribe(listener), register: vi.fn(() => () => {}), } - runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) + if (withBrowserRegistry) runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openWorkspacePath = vi.fn( () => Promise.resolve({ ok: true, value: { opened: true } }), @@ -88,9 +99,10 @@ async function bench() { runtime.slots.installLocale(locale) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) - await runtime.mount({ inject: [...injectChat], apply: applyChat }) + const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) runtime.renderRoot() const chatViewApi = (reference: SessionReference) => { @@ -104,7 +116,13 @@ async function bench() { return { instance, injected } } return { - runtime, layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession, + get linkPreference() { + const row = runtime.slots.entries('settings.general.item').find(entry => entry.options.id === 'link-opening')! + const preference: object = row.inject!() + return preference as LinkOpeningRowInjected + }, + runtime, chat, chatSettings, browserAvailable, + layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession, } } @@ -184,6 +202,106 @@ describe('Chat inject API', () => { } }) + it('applies restored and live link destinations without remounting the Chat view', async () => { + const settings: ChatSettings = { transcriptView: 'compact', performanceUsage: 'detailed', linkOpening: 'new-tab' } + const b = await bench(settings) + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + const { injected } = b.chatViewApi(b.rootReference) + const source = b.linkPreference.hooks.linkOpening + expect(source.getSnapshot()).toBe('new-tab') + injected.openExternalLink('https://example.test/restored') + expect(open).toHaveBeenLastCalledWith('https://example.test/restored', '_blank', 'noopener,noreferrer') + expect(b.sidebarRight.openTab).not.toHaveBeenCalled() + + b.chatSettings.publish({ value: { ...settings, linkOpening: 'sidebar' } }) + expect(source.getSnapshot()).toBe('sidebar') + injected.openExternalLink('http://example.test/live') + expect(b.sidebarRight.openTab).toHaveBeenLastCalledWith('browser', { params: { url: 'http://example.test/live' } }) + + b.linkPreference.setLinkOpening('new-tab') + expect(b.chatSettings.set).toHaveBeenCalledWith('linkOpening', 'new-tab') + injected.openExternalLink('http://example.test/selected') + expect(open).toHaveBeenLastCalledWith('http://example.test/selected', '_blank', 'noopener,noreferrer') + + await b.chat.dispose() + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + b.chatSettings.publish({ value: { ...settings, linkOpening: 'sidebar' } }) + expect(source.getSnapshot()).toBe('new-tab') + } finally { + open.mockRestore() + await b.runtime.dispose() + } + }) + + it('follows browser registration and routes absent browsers externally', async () => { + const b = await bench() + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const available = b.linkPreference.hooks.browserAvailable + const changed = vi.fn() + const unsubscribe = available.subscribe(changed) + try { + const { injected } = b.chatViewApi(b.rootReference) + expect(available.getSnapshot()).toBe(true) + b.browserAvailable.set(false) + expect(available.getSnapshot()).toBe(false) + expect(changed).toHaveBeenCalledOnce() + injected.openExternalLink('https://example.test/disabled') + expect(open).toHaveBeenCalledWith('https://example.test/disabled', '_blank', 'noopener,noreferrer') + b.browserAvailable.set(true) + injected.openExternalLink('https://example.test/enabled') + expect(b.sidebarRight.openTab).toHaveBeenCalledWith('browser', { params: { url: 'https://example.test/enabled' } }) + expect(b.linkPreference.hooks.linkOpening.getSnapshot()).toBe('sidebar') + } finally { + unsubscribe() + open.mockRestore() + await b.runtime.dispose() + } + }) + + it('keeps Chat available while the optional tab registry appears and leaves', async () => { + const b = await bench(undefined, false) + try { + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(1) + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + const registry = await b.runtime.mount({ + apply(ctx) { ctx.provide('sidebarRightTabs', b.sidebarRightTabs as never) }, + }) + await vi.waitFor(() => { + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(true) + }) + await registry.dispose() + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(1) + } finally { + await b.runtime.dispose() + } + }) + + it('keeps local link choices usable when settings cannot persist', async () => { + const b = await bench() + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + const { injected } = b.chatViewApi(b.rootReference) + b.chatSettings.publish({ mode: 'memory', status: 'unavailable' }) + b.chatSettings.set.mockResolvedValueOnce(false) + b.linkPreference.setLinkOpening('new-tab') + b.chatSettings.publish({ value: undefined }) + injected.openExternalLink('https://example.test/local') + expect(open).toHaveBeenCalledWith('https://example.test/local', '_blank', 'noopener,noreferrer') + expect(b.sidebarRight.openTab).not.toHaveBeenCalled() + + b.chatSettings.set.mockRejectedValueOnce(new Error('settings unavailable')) + b.linkPreference.setLinkOpening('sidebar') + await Promise.resolve() + injected.openExternalLink('https://example.test/local-sidebar') + expect(b.sidebarRight.openTab).toHaveBeenCalledWith('browser', { params: { url: 'https://example.test/local-sidebar' } }) + } finally { + open.mockRestore() + await b.runtime.dispose() + } + }) + it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 028752d24c..e085bba34e 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -47,7 +47,11 @@ async function bench() { } as never) runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never) - runtime.ctx.provide('sidebarRightTabs', { register: vi.fn(() => () => {}) } as never) + runtime.ctx.provide('sidebarRightTabs', { + register: vi.fn(() => () => {}), + get: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}), + } as never) runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openSession = vi.fn<(id: SessionId) => void>() runtime.ctx.provide('uiWorkspace', { @@ -94,7 +98,7 @@ describe('Chat apply wiring', () => { expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id)) .toEqual(['stats']) expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id)) - .toEqual(['transcript-view', 'performance-usage', 'composer-enter']) + .toEqual(['transcript-view', 'performance-usage', 'link-opening', 'composer-enter']) await b.runtime.dispose() }) @@ -110,7 +114,7 @@ describe('Chat apply wiring', () => { expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal') b.chatSettings.publish({ - status: 'ready', value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, + status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, }) expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') await b.runtime.dispose() @@ -123,7 +127,7 @@ describe('Chat apply wiring', () => { expect(face.hooks.performanceUsage.getSnapshot()).toBe('detailed') face.setPerformanceUsage('compact') expect(b.chatSettings.set).toHaveBeenCalledWith('performanceUsage', 'compact') - b.chatSettings.publish({ value: { transcriptView: 'compact', performanceUsage: 'compact' } }) + b.chatSettings.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'compact' } }) expect(face.hooks.performanceUsage.getSnapshot()).toBe('compact') for (const entry of [ b.runtime.slots.entries('conversation.composer.dock').find(entry => entry.options.id === 'stats')!, diff --git a/packages/client/ui-chat/tests/chat-settings.client.spec.ts b/packages/client/ui-chat/tests/chat-settings.client.spec.ts index ad5ae6ab89..d2c146c40d 100644 --- a/packages/client/ui-chat/tests/chat-settings.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-settings.client.spec.ts @@ -21,14 +21,20 @@ describe('ui-chat Host settings', () => { await fiber.await() const ns = CHAT_SETTINGS_NAMESPACE - expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed' }) + expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' }) await ctx.settings.update(ns, { transcriptView: 'normal' }) - expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed' }) + expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' }) await expect(ctx.settings.update(ns, { transcriptView: 'dense' })).rejects.toThrow() await ctx.settings.update(ns, { performanceUsage: 'compact' }) expect(ctx.settings.get(ns)).toMatchObject({ performanceUsage: 'compact' }) await expect(ctx.settings.update(ns, { performanceUsage: 'hidden' })).rejects.toThrow() + await ctx.settings.update(ns, { linkOpening: 'new-tab' }) + expect(ctx.settings.get(ns)).toMatchObject({ linkOpening: 'new-tab' }) + await ctx.settings.update(ns, { linkOpening: 'sidebar' }) + expect(ctx.settings.get(ns)).toMatchObject({ linkOpening: 'sidebar' }) + await expect(ctx.settings.update(ns, { linkOpening: 'popup' })).rejects.toThrow() + await fiber.dispose() expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) }) diff --git a/packages/client/ui-chat/tests/performance-usage.client.spec.ts b/packages/client/ui-chat/tests/performance-usage.client.spec.ts index 00e862cfe6..54ddfdd1de 100644 --- a/packages/client/ui-chat/tests/performance-usage.client.spec.ts +++ b/packages/client/ui-chat/tests/performance-usage.client.spec.ts @@ -21,10 +21,10 @@ describe('PerformanceUsagePolicy', () => { it('adopts accepted Host settings at construction and after updates', () => { const host = stubSettingsScope() - host.publish({ value: { transcriptView: 'normal', performanceUsage: 'compact' } }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'compact' } }) const policy = new PerformanceUsagePolicy(host.scope) expect(policy.mode.getSnapshot()).toBe('compact') - host.publish({ value: { transcriptView: 'normal', performanceUsage: 'detailed' } }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' } }) expect(policy.mode.getSnapshot()).toBe('detailed') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts index 84717bb5c0..ec8595496a 100644 --- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -30,18 +30,18 @@ describe('TranscriptViewPolicy', () => { const host = stubSettingsScope() const policy = new TranscriptViewPolicy(host.scope) - host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) expect(policy.mode.getSnapshot()).toBe('normal') policy.setMode('normal') expect(host.set).not.toHaveBeenCalled() - host.publish({ value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) expect(policy.mode.getSnapshot()).toBe('compact') }) it('adopts an accepted section standing at construction', () => { const host = stubSettingsScope() - host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index ca5aeb1e00..82c928ac97 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -9,7 +9,8 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx' -import type { PerformanceUsageMode } from '../src/chat-settings.ts' +import type { LinkOpening, PerformanceUsageMode } from '../src/chat-settings.ts' +import { LinkOpeningRow } from '../src/client/settings/LinkOpeningRow.tsx' import { en, zh } from '../src/client/locale.ts' afterEach(cleanup) @@ -80,6 +81,50 @@ describe('TranscriptViewRow', () => { }) +describe('LinkOpeningRow', () => { + it('follows browser availability without clearing the saved destination', () => { + const b = mount() + const browserAvailable = createSnapshotStore(false) + const source = createSnapshotStore('new-tab') + render() + expect(screen.queryByText('Open chat links in')).toBeNull() + act(() => { browserAvailable.set(true) }) + expect(screen.getByRole('button', { name: 'New browser tab' })).toBeDefined() + act(() => { browserAvailable.set(false) }) + expect(screen.queryByText('Open chat links in')).toBeNull() + expect(source.getSnapshot()).toBe('new-tab') + }) + + it.each([ + [en, 'Open chat links in', 'Built-in browser', 'New browser tab'], + [zh, '聊天链接打开方式', '内置浏览器', '浏览器新标签页'], + ] as const)('selects either destination using localized labels (%s)', (dictionary, title, sidebar, newTab) => { + const b = mount('compact', dictionary) + const source = createSnapshotStore('sidebar') + const setLinkOpening = vi.fn((destination: LinkOpening) => { source.set(destination) }) + render() + expect(screen.getByText(title)).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: sidebar })) + fireEvent.click(screen.getByRole('menuitem', { name: newTab })) + expect(setLinkOpening).toHaveBeenLastCalledWith('new-tab') + fireEvent.click(screen.getByRole('button', { name: newTab })) + fireEvent.click(screen.getByRole('menuitem', { name: sidebar })) + expect(setLinkOpening).toHaveBeenLastCalledWith('sidebar') + expect(screen.getByRole('button', { name: sidebar }).getAttribute('aria-expanded')).toBe('false') + expect(b.setTranscriptView).not.toHaveBeenCalled() + }) +}) + describe('PerformanceUsageRow', () => { it('selects compact statistics independently of conversation display', () => { const b = mount() diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 11f6083a2a..2037b3edd7 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -23,8 +23,9 @@ vi.mock(desktopIpc.electron, () => ({ vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../../../../apps/desktop/src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) +vi.mock('../../../../apps/desktop/src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); desktopIpc.invoke.mockReset() }) const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const @@ -164,7 +165,9 @@ describe('directory-picker-native client half', () => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) - it('consumes the actual Desktop preload bridge and sends its directory-pick IPC', async () => { + it.each(['win32', 'darwin'] as const)('consumes the actual Desktop preload directory bridge on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + vi.resetModules() vi.stubGlobal('location', new URL('dsh-app://app/')) // Desktop's preload is typechecked by its own compiler program. const preload = '../../../../apps/desktop/src/preload-app.ts' diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 74be8bbf76..24fc6a38eb 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md -README.md: b6072cd7d75446a0a45abe4009273235e4ff4e1b -README.zh.md: 44b5ce85ccd6250f30904f80b01ee083f804f1ce +README.md: 96ecd3777ef0ce4ce6babd977eef055ad5c683ec +README.zh.md: fb6eb850b868913bdccec175500ff4e26d7a2157 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index b6072cd7d7..96ecd3777e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. -Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 44b5ce85cc..fb6eb850b8 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index e3dff30e29..d44b8f2dd9 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -74,7 +74,7 @@ export function parsePlanAddress(address: string): PlanAddress | undefined { if (parts.length === 2) return { session: { kind: 'session', sessionId: parts[0] as SessionId }, callId: parts[1] as ToolCallId, } - if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable')) return { + if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable' || parts[3] === 'unknown')) return { session: { kind: 'subagent', parentSessionId: parts[1] as SessionId, childSessionId: parts[2] as SessionId, mode: parts[3] }, callId: parts[4] as ToolCallId, } diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index a27b03c185..57be9acd36 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -36,7 +36,7 @@ describe('plan history resource', () => { expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) expect(b.closed).toHaveBeenCalledOnce() }) - it.each(['one-shot', 'continuable'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { + it.each(['one-shot', 'continuable', 'unknown'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) const address = `dsh-resource://plan/subagent/parent/child/${mode}/call` const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index dfbe7fcedb..df24d45717 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: d7bcd2299d93dab90c8440565ac87112b37c9c10 -README.zh.md: 368058dabc27968b86088c34ee387dc5dd5bd364 +README.md: c04a614668d5afb2e09c1f923ac9be473a4aab5a +README.zh.md: f0b30c91cb87ce96349d92086673809139956e0a diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index d7bcd2299d..c04a614668 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. -Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Installation previews still use registry or manifest information. +Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. ### Installing a bundle diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 368058dabc..f0b30c91cb 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 -已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。安装预览仍使用注册表或 manifest 信息。 +已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 ### 安装一个组合包 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 96286b3b16..a5f1da6197 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -202,6 +202,10 @@ color: var(--dsw-alias-label-secondary); } +.packageImage { + object-fit: contain; +} + .cardMain { flex: 1; min-width: 0; @@ -1112,13 +1116,13 @@ label.registryOption { line-height: 22px; } -/* The package name under the title: the exact spec that installs the package - elsewhere, where a title of its own or a shortened name hides it. */ +/* The package or row identity under the title: the exact spec that installs the + package elsewhere, where a title of its own or a shortened name hides it. */ .detailName { margin: 0; font-size: 12px; line-height: 18px; - color: var(--dsw-alias-label-caption); + color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 5235d53cd0..5ef0212a3c 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -17,7 +17,7 @@ import { IconChevronRightOutlineRegular, IconCloseOutlineMedium, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, IconWarningOutlineRegular, Input, Modal, - PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal, + PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -130,21 +130,23 @@ const ITEM_ARTWORK = new Map ReactNode>([ ['web-search', PluginArtworkSearch], ]) -/** The artwork of the official bundles with artwork of their own, by package name. */ -const PACKAGE_ARTWORK = new Map ReactNode>([ - ['@deepseek-ai/dsh-experimental-agent-team-profile', PluginArtworkTeam], -]) - /** An official plugin's card and page artwork; plugins without their own get the default. */ function itemArtwork(id: string): ReactNode { const Artwork = ITEM_ARTWORK.get(id) ?? PluginArtworkDefault return } -/** A package's card and page artwork; packages without their own get the default. */ -function packageArtwork(name: string): ReactNode { - const Artwork = PACKAGE_ARTWORK.get(name) ?? PluginArtworkDefault - return +/** Manifest images remain isolated from the page DOM; a failed decode keeps the position's default artwork. */ +function PackageArtwork({ src, row = false, size = row ? ROW_ARTWORK_SIZE : CARD_ARTWORK_SIZE }: { + readonly src: string | undefined + readonly row?: boolean + readonly size?: number +}): ReactNode { + const [failedSource, setFailedSource] = useState() + const Fallback = row ? PluginArtworkSubagent : PluginArtworkDefault + return src === undefined || src === failedSource + ? + : { setFailedSource(src) }} /> } /** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */ @@ -234,7 +236,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { {...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }} >
- +
{configure?.has(row) === true ? ( @@ -367,7 +369,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnab title={title} t={t} onOpen={onOpen} - icon={packageArtwork(pkg.name)} + icon={} tags={( <> {beta ? {t('statusBeta')} : null} @@ -439,7 +441,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot }: { const key = rowConfigKey(pkg.name, row.rowId) return (
- } /> + } />

{rowTitle}

@@ -491,7 +493,7 @@ function PackageDetail({ crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} - icon={packageArtwork(pkg.name)} + icon={} actions={(
{pkg.installed diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 4c885f8834..9aaa8c1cee 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -101,26 +101,30 @@ function renderTab(state: Partial = {}, config: Partial { - if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) { - throw new Error('Configuration slot requires a summary or page view') - } - return bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`]?.(owner.view) ?? null + const unusedStandardHook = (): never => { throw new Error('Plugin manager fixture does not provide global state') } + const standard = { + usePanelInfo: unusedStandardHook, + useWorkspaces: unusedStandardHook, + useSessions: unusedStandardHook, + useSessionStatus: unusedStandardHook, + useSessionRetainInfo: unusedStandardHook, + useResource: unusedStandardHook, } - const unusedGlobalHook = (): never => { throw new Error('PluginManagerPage does not consume global runtime hooks') } const props: PluginManagerPageProps = { - useSessions: unusedGlobalHook, - useSessionStatus: unusedGlobalHook, - useSessionRetainInfo: unusedGlobalHook, - usePanelInfo: unusedGlobalHook, - useResource: unusedGlobalHook, - useWorkspaces: unusedGlobalHook, + ...standard, t, resolveText, ...actions, usePluginManager: bindSnapshotSelector(store), useConfigLedger: bindSnapshotSelector(ledger), - renderSlot, + renderSlot: (name, owner, opts) => { + const body = bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`] + if (body === undefined) return null + if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) { + throw new Error('Plugin configuration fixture requires a summary or page view') + } + return body(owner.view) + }, } const { rerender } = render() return { @@ -292,6 +296,38 @@ describe('PluginManagerPage', () => { } }) + it('renders manifest icons for arbitrary bundles and rows, with decode fallback and source recovery', () => { + const icon = 'data:image/svg+xml;base64,PHN2Zy8+' + const updatedIcon = 'data:image/png;base64,cG5n' + const bundle = pkg({ meta: { icon }, rows: [row({ meta: { icon } }), row({ entryId: 'plain' as PluginEntryId, rowId: 'plain', moduleName: 'plain' })] }) + const { set } = renderTab({ packages: [bundle] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }) + const image = () => document.querySelector('[data-plugin-package] img, [data-plugin-detail] img')! + expect(image().getAttribute('src')).toBe(icon) + expect(image().getAttribute('alt')).toBe('') + expect(image().width).toBe(36) + fireEvent.error(image()) + expect(document.querySelector('[data-plugin-package] img')).toBeNull() + expect(document.querySelector('[data-plugin-package] svg')).not.toBeNull() + set({ packages: [{ ...bundle, meta: { icon: updatedIcon } }] }) + expect(image().getAttribute('src')).toBe(updatedIcon) + set({ packages: [bundle] }) + expect(image().getAttribute('src')).toBe(icon) + fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' })) + expect(image().getAttribute('src')).toBe(icon) + const rowImage = document.querySelector('[data-plugin-row] img')! + expect(rowImage.getAttribute('src')).toBe(icon) + expect(rowImage.width).toBe(30) + expect(document.querySelector('[data-plugin-row="plain"] img')).toBeNull() + expect(document.querySelector('[data-plugin-row="plain"] svg')).not.toBeNull() + fireEvent.error(rowImage) + expect(document.querySelector('[data-plugin-row] img')).toBeNull() + expect(document.querySelector('[data-plugin-row] svg')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Configure dsh-better-sidebar' })) + const detailImage = document.querySelector('[data-plugin-row-detail] img')! + expect(detailImage.getAttribute('src')).toBe(icon) + expect(detailImage.width).toBe(36) + }) + it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => { const error = 'locale/zh.json: invalid title' const { actions, set, setLanguage } = renderTab({ diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d8df0b30d6..e6884489fc 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 51ad53a79a676f013b61213764479a1247de522c -README.zh.md: c41d19af1855c8ab1582fa92b956ef283d3466cf +README.md: f17f80d877a76d61b55b502edb05781f8e05cee4 +README.zh.md: 8540a82389b4dced4e6b6048980f4278ec5722d7 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 51ad53a79a..f17f80d877 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -57,7 +57,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | -| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | | `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c41d19af18..8540a82389 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -57,7 +57,7 @@ kind: "package-library" | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | -| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | | `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 | diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 2ad31f82b4..d98ad76158 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -83,5 +83,5 @@ export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' export * from './icons/index.tsx' export { - PluginArtworkTeam, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault, + PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault, } from './plugin-artwork.tsx' diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx index ec54304d3c..c5a774ca81 100644 --- a/packages/client/ui-primitives/src/plugin-artwork.tsx +++ b/packages/client/ui-primitives/src/plugin-artwork.tsx @@ -12,25 +12,6 @@ import type { IconProps } from './icons/props.ts' */ const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}` -/** Agent-team plugin artwork (four interlocking quadrants). */ -export const PluginArtworkTeam = ({ size = 36, className }: IconProps) => { - const uid = useArtworkId() - return ( - - - - - - - - - - - - - ) -} - /** Terminal plugin artwork (prompt chevron and cursor bar). */ export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx index 0df9c9a573..5db7f79cb8 100644 --- a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx +++ b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx @@ -2,13 +2,12 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { - PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal, + PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) const artworks = { - PluginArtworkTeam, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 2f86bc5b7c..5ddaacb419 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: ebdb22adc071b5df435fddd6620dd982d866aa36 -README.zh.md: 0bb188ea5024b14e68baa1a687a654ee8963f3d7 +README.md: 2337b7db8f5b8d2713d17f83092d948657a5acdb +README.zh.md: 2fd25f3d1b44c400b4400397e94d641766d9ae62 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index ebdb22adc0..2337b7db8f 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -27,6 +27,8 @@ Use this package to give the dsh web client a Settings panel, connection-recover Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows the shared ongoing loader beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. +When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed. + In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a dot on its top expand button. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. ### The General section diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 0bb188ea50..2fd25f3d1b 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示共享 ongoing loading 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 +当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。 + 在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以圆点显示相同状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。 ### 「通用」分区 diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 783eea44a9..8e7569b3ca 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -79,7 +79,7 @@ One height for every section, taken from the viewport rather than the content: sections differ by hundreds of pixels (a settings list against the composition editor), and a content-sized panel would resize under the - pointer on every nav click. Whatever does not fit scrolls in `.options`. + pointer on every nav click. The navigation and options scroll independently. 24px of air above and below, widened to the frame's published top clearance (the macOS window strip, where clicks drag the window) on both sides so the panel sits centered and clear of the strip. */ @@ -129,6 +129,7 @@ display: flex; flex-direction: column; gap: 4px; + overflow-y: auto; } /* Nav cell (figma .Setting-nav-cell 501:29962): 164x40, r12, pad diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c3c33c6f90..a0ed3e4fbe 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: c1567814b5de39f9b8819fcc8848e36c0a6ff842 -README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27 +README.md: a47f9a6dfa1faa9257c9b726140838e4858d33bb +README.zh.md: 925f8e976559ef7e975fcd164406f8c42f0b0098 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index c1567814b5..a47f9a6dfa 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -37,7 +37,7 @@ The primary field on an editor card is a single **API key** input — the page n The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, and input types; unrelated model fields survive edits. -The DeepSeek card edits the shared `llm-deepseek` endpoint, credentials, and model catalog without a protocol selector. When Cordis YAML selects Messages, the public endpoint placeholder is `https://api.deepseek.com/anthropic`. Saving the card preserves protocol configuration. +The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `settings.yaml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index baa0f02e82..925f8e9765 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens` 和输入类型;无关的模型字段在编辑后仍会保留。 -`llm-deepseek` 的 DeepSeek 卡片编辑共用的端点、凭据和模型目录,不提供协议选择器。Cordis YAML 选择 Messages 时,官方端点占位符为 `https://api.deepseek.com/anthropic`;保存卡片不会改写协议配置。 +`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 展开**自定义设置 → 模型选项**编辑模型。两个提供方家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供方默认值,最后回退为文本。已知 pi-ai 提供方会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `settings.yaml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index bc89f43fc8..0f21aaff9a 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -414,7 +414,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { type="text" value={stringAt(draft, 'baseURL') ?? ''} placeholder={family === 'deepseek' - ? t(stringAt(fallback, 'protocol') === 'messages' ? 'deepSeekMessagesBaseUrl' : 'deepSeekChatBaseUrl') + ? t('deepSeekBaseUrl') : stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')} aria-describedby={family === 'deepseek' ? `${props.provider}-endpoint-hint` : undefined} aria-label={t('baseUrl')} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 2b0f92d8df..81f1c011c9 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -35,9 +35,8 @@ export const en = { customized: 'Customized settings', baseUrl: 'Base URL', baseUrlDefault: 'Provider default', - deepSeekChatBaseUrl: 'https://api.deepseek.com', - deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic', - deepSeekEndpointHint: 'Use an endpoint compatible with the configured connection.', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', + deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.', models: 'Models', modelsInherited: 'Using the adapter defaults', modelsCustomized: 'Customized model catalog', @@ -147,9 +146,8 @@ export const zh: { [Key in keyof typeof en]: string } = { customized: '自定义设置', baseUrl: 'API 地址', baseUrlDefault: '提供方默认', - deepSeekChatBaseUrl: 'https://api.deepseek.com', - deepSeekMessagesBaseUrl: 'https://api.deepseek.com/anthropic', - deepSeekEndpointHint: '请填写与当前连接配置兼容的 API 地址。', + deepSeekBaseUrl: 'https://api.deepseek.com/anthropic', + deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。', models: '模型目录', modelsInherited: '正在使用适配器默认模型', modelsCustomized: '已自定义模型目录', diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e9cec03e60..14236346c1 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -618,7 +618,7 @@ describe('ModelsSection', () => { const baseURL = screen.getByLabelText(en.baseUrl) // The deepseek placeholder is pinned to the public endpoint, not the // effective value (which may reflect a launch-environment override). - expect(baseURL.placeholder).toBe('https://api.deepseek.com') + expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic') fireEvent.change(baseURL, { target: { value: 'https://next2' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -666,11 +666,11 @@ describe('ModelsSection', () => { ]) }) - it('edits the shared DeepSeek card while preserving the YAML protocol selection', async () => { + it('edits the DeepSeek endpoint and credential without a protocol selection', async () => { const namespace: SettingsNamespaceView = { ...wireNamespaces()[0]!, ns: 'llm-deepseek', - value: { protocol: 'messages', apiKeyEnv: 'DEEPSEEK_API_KEY', models: DEFAULT_DEEPSEEK_MODELS }, + value: { apiKeyEnv: 'DEEPSEEK_API_KEY', models: DEFAULT_DEEPSEEK_MODELS }, user: {}, } const { face, mutate, set } = scriptedFace({ @@ -1102,7 +1102,7 @@ describe('ModelsSection', () => { />) fireEvent.click(screen.getByText(en.customized)) const baseURL = screen.getByLabelText(en.baseUrl) - expect(baseURL.placeholder).toBe('https://api.deepseek.com') + expect(baseURL.placeholder).toBe('https://api.deepseek.com/anthropic') fireEvent.change(baseURL, { target: { value: 'https://x' } }) expect(baseURL.value).toBe('https://x') fireEvent.change(baseURL, { target: { value: '' } }) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index bbf984fb20..5358b03f67 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -178,13 +178,20 @@ font-weight: 600; } +/* The Loader entry id keeps one size in the card subtitle and the row it expands into; + the collapsed subtitle stays on the quieter label tier, while the expanded row carries + the card's primary value. */ +.cardIdentity, +.entryValue { + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + .cardIdentity { display: block; width: 100%; color: var(--dsw-alias-label-tertiary); - font-family: var(--ds-font-family-code); - font-size: 11px; - line-height: 16px; } .cardTrailing { @@ -383,9 +390,6 @@ display: block; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); - font-family: var(--ds-font-family-code); - font-size: 12px; - line-height: 18px; } .details { diff --git a/packages/client/ui-sidebar-browser/README.i18n.yaml b/packages/client/ui-sidebar-browser/README.i18n.yaml index 533b9d85e2..455edd84ea 100644 --- a/packages/client/ui-sidebar-browser/README.i18n.yaml +++ b/packages/client/ui-sidebar-browser/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-browser/README.md -README.md: 61de40ef98c72501c20c56cffb1075322bcb4886 -README.zh.md: 8bdc88a85f2473e62c640e509db53577c1f2e6d4 +README.md: dcfaccdfb85cdada9300aadb2f6e9058dc5a95bc +README.zh.md: 8d5c34ab4c1b4d87e2a25912f38f102442f7a454 diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md index 61de40ef98..dcfaccdfb8 100644 --- a/packages/client/ui-sidebar-browser/README.md +++ b/packages/client/ui-sidebar-browser/README.md @@ -25,7 +25,7 @@ Browse HTTP(S) pages, including loopback services, inside independent right-Side ## Use this package -The shipped Web and Desktop compositions already mount this package. Open **Browser** from the right-Sidebar guide, enter an HTTP(S) URL, or select an HTTP(S) link in Assistant Markdown. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or message-link activation creates another Browser tab. +Browser is disabled by default in Web profiles and enabled on Desktop. Enable the shipped entry through the Web profile patch to use it. Open **Browser** from the right-Sidebar guide and enter an HTTP(S) URL. Chat HTTP(S) links open here when the [link preference](../ui-chat/README.md) selects the built-in browser. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or delegated message-link activation creates another Browser tab. ### When to choose it @@ -33,11 +33,11 @@ Choose Browser for a Web page that should remain beside the current Session. Cho ### Minimal configuration -The package has no configuration. A custom Web composition mounts its Host companion; the Client loader then discovers the browser entry declared by the package manifest: +The package has no plugin configuration. A Web profile enables the shipped entry through its profile patch: ```yaml - id: ui-sidebar-browser - name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: false ``` Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation. diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md index 8bdc88a85f..8d5c34ab4c 100644 --- a/packages/client/ui-sidebar-browser/README.zh.md +++ b/packages/client/ui-sidebar-browser/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -随附的 Web 与 Desktop composition 已挂载本包。可以从右侧 Sidebar guide 打开 **浏览器**、输入 HTTP(S) URL,或点击 Assistant Markdown 中的 HTTP(S) 链接。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或消息链接操作都会创建一个新的 Browser tab。 +Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用户可通过 profile patch 启用随附条目。可以从右侧 Sidebar guide 打开 **浏览器**并输入 HTTP(S) URL。Chat 的[链接偏好](../ui-chat/README.zh.md)选择内置浏览器时,HTTP(S) 链接会在此打开。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或委托到此的消息链接操作都会创建一个新的 Browser tab。 ### 何时选择 @@ -33,11 +33,11 @@ kind: "package-reference" ### 最小配置 -本包没有配置字段。自定义 Web composition 挂载 Host companion;随后 Client loader 会发现 package manifest 声明的浏览器入口: +本包没有插件配置字段。Web profile 通过其 profile patch 启用随附条目: ```yaml - id: ui-sidebar-browser - name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: false ``` Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。 diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 4d8af72e49..9dc14fc396 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md -README.md: c29bcfeb04385a2c65cb6469cac3a54232bdf04c -README.zh.md: a0a4402ca8b3fc9e256fa34ca1659aad05c1d030 +README.md: 75f1e46d55a540abdb6d1bb36526088af871d6cb +README.zh.md: 55173646ea63884b7803b951a2189acb68695f00 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index c29bcfeb04..75f1e46d55 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -65,6 +65,8 @@ Each visible catalog level advances its own clock while it contains a running ch One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. +Unknown-mode catalog rows remain visible and clickable, using the child id when no label is available. Their composer is read-only until child history establishes a supported mode; a read failure is reported in the child conversation. + ----- diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index a0a4402ca8..55173646ea 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -65,6 +65,8 @@ kind: "package-reference" one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 +模式未知的目录项仍可见、可点击,没有标签时显示子会话 id。读取子历史并确认受支持模式前,输入区保持只读;读取失败在该子会话中报告。 + ----- diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index bc4526b1ee..2aaf0b9820 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -239,7 +239,8 @@ function CatalogRows({ || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const summary = summaries[entry.id] const label = entry.label ?? entry.id - const mode = external !== undefined ? t('mode.external') : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') + const mode = external !== undefined ? t('mode.external') : entry.mode === 'unknown' ? t('mode.unknown') + : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') const completed = external !== undefined ? external === 'completed' : entry.activity === 'inactive' && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true const activity = external !== undefined ? t(`external.${external}`) : entry.activity === 'running' diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 158e8cb77b..07feb84f55 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -4,7 +4,7 @@ import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ export interface SubagentReadOnlyMatch { - reason: 'one-shot' | 'parent-unavailable' + reason: 'one-shot' | 'parent-unavailable' | 'unknown' } /** Full chain props after the read-only subagent selector accepts the owner currency. */ @@ -24,7 +24,7 @@ export function SubagentReadOnlyComposer({
{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')} + {t(matched.reason === 'unknown' ? 'readonly.unknown.body' : oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 0055ea8c3f..d0779cddc8 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -37,6 +37,7 @@ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRig function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null + if (subagent.address.mode === 'unknown') return { reason: 'unknown' } if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } // Until a Host summary establishes parent availability, keep the normal // disabled composer instead of claiming that the parent is offline. diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 6afef047d8..52eb13e8d5 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -31,6 +31,8 @@ export const zh = { 'external.max-tokens': '已达输出上限', 'mode.oneShot': '一次性', 'mode.continuable': '可继续', + 'mode.unknown': '模式未知', + 'readonly.unknown.body': '读取子会话后才能确定是否可继续。', 'activity.running': '正在运行', 'activity.completed': '已完成', 'activity.inactive': '当前未运行', @@ -78,6 +80,8 @@ export const en: Record = { 'external.max-tokens': 'Output limit reached', 'mode.oneShot': 'one-shot', 'mode.continuable': 'continuable', + 'mode.unknown': 'unknown mode', + 'readonly.unknown.body': 'Read the child session to determine whether it can be continued.', 'activity.running': 'running', 'activity.completed': 'completed', 'activity.inactive': 'not running', diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx index a4fe489714..fde31ff638 100644 --- a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -73,7 +73,7 @@ export function parseSubagentChatAddress(value: string): SubagentAddress | undef if (parts.length !== 2 || parts[0] !== 'session') return undefined const parentSessionId = url.searchParams.get('parent') const mode = url.searchParams.get('mode') - if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable')) { + if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable' && mode !== 'unknown')) { return undefined } try { diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 49b2ac6016..a8e87cb283 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -152,6 +152,8 @@ describe('apply', () => { // One-shot stays read-only even while running: it has no stop action. expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) .toEqual({ reason: 'one-shot' }) + expect(select(owner({ address: { ...address, mode: 'unknown' }, parentAvailable: true }))) + .toEqual({ reason: 'unknown' }) expect(select(owner({ address }))).toBeNull() expect(select(owner({ address, parentAvailable: true }))).toBeNull() expect(select(owner({ address, parentAvailable: false }))) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index c9008ac7bb..523afc6f40 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -25,7 +25,7 @@ const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh) -type CatalogFixture = { entries: readonly SubagentCatalogRow[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] } +type CatalogFixture = { entries: readonly (SubagentCatalogRow | { id: SessionId; mode: 'unknown'; label?: string; activity: 'inactive' })[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] } function catalog(over: Partial = {}): CatalogFixture { return { @@ -402,6 +402,16 @@ describe('SubagentHeaderLineage', () => { }) }) + it('keeps unknown catalog children clickable by their durable parent address', () => { + const input = props(catalog({ entries: [{ id: CHILD, mode: 'unknown', activity: 'inactive' }] })) + render() + hoverCatalog(screen.getByRole('button', { name: /子代理/ })) + const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) }) + expect(row.textContent).toContain('模式未知') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'unknown' }) + }) + it('shows durable completion and token totals, ticks active duration, and freezes inactive rows', async () => { const now = 2_000_000_000_000 const minute = 60_000 @@ -926,6 +936,11 @@ describe('SubagentReadOnlyComposer', () => { expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) + it('keeps an unknown child read-only until its descriptor is available', () => { + render() + expect(screen.getByRole('status').textContent).toContain('读取子会话后才能确定是否可继续') + }) + it('explains that one-shot histories never accept follow-ups', () => { render() expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') diff --git a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx index 143a0cb595..0348f9c786 100644 --- a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx +++ b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx @@ -38,6 +38,8 @@ describe('Sidebar chat address', () => { expect(parseSubagentChatAddress(resource)).toEqual(ADDRESS) expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'one-shot' }))) .toEqual({ ...ADDRESS, mode: 'one-shot' }) + expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'unknown' }))) + .toEqual({ ...ADDRESS, mode: 'unknown' }) }) it.each([ @@ -47,7 +49,7 @@ describe('Sidebar chat address', () => { 'dsh-resource://subagentchat/other/child?parent=parent&mode=continuable', 'dsh-resource://subagentchat/session/child?mode=continuable', 'dsh-resource://subagentchat/session/child?parent=&mode=continuable', - 'dsh-resource://subagentchat/session/child?parent=parent&mode=unknown', + 'dsh-resource://subagentchat/session/child?parent=parent&mode=invalid', 'dsh-resource://subagentchat/session/%?parent=parent&mode=continuable', ])('rejects %s', (address) => { expect(parseSubagentChatAddress(address)).toBeUndefined() diff --git a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts index 2123638197..001b307a65 100644 --- a/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts +++ b/packages/compaction/compaction-image-offload/tests/image-offload.spec.ts @@ -10,9 +10,10 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic' import TokenMeter from '@deepseek-ai/dsh-token-meter' import { ImageVariantId } from '@deepseek-ai/dsh-attachment' -import { serializeRequestWithImages } from '@deepseek-ai/dsh-llm-deepseek/src/protocols/chat-completions/serialize.ts' +import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import { inlineImages } from '@deepseek-ai/dsh-llm-deepseek/src/images.ts' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createToolResultMessage, createUserMessage, projectOffloadedImages, offloadedImageText, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { isReplacementSurfaceEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' @@ -32,15 +33,15 @@ class ScriptedAdapter extends LlmAdapter { async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) if (this.serializeSummary && options.purpose === 'compaction') { - await serializeRequestWithImages(options, { - representation: { kind: 'base64' }, - requestImages: new Map([image('first').attachment].map(ref => [ref.attachmentId, { + inlineImages( + projectOffloadedImages(options.messages, ref => offloadedImageText(ref)), + new Map([image('first').attachment].map(ref => [ref.attachmentId, { variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), attachment: ref, data: new Uint8Array(ref.bytes), mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height, depth: 'uchar', space: 'srgb', hasAlpha: false, }])), - maxRequestImageBytes: 1, - }) + resolveAdapterOptions({ maxInlineRequestImageBytes: 1, inlineImageOffloadByteQuantum: 1 }), + ) } const entry = this.script.shift() if (entry === undefined) throw new Error('script exhausted') @@ -132,7 +133,7 @@ describe('summary image offload', () => { expect(decisions(agent.session)).toEqual([]) }) - it('recovers the real summary serializer with a tighter image budget and fresh pricing', async () => { + it('recovers real summary image preparation with a tighter budget and fresh pricing', async () => { const { compact, agent, adapter } = await summaryHarness([textResponse('answer'), textResponse('checkpoint')]) const span = await seedImages(agent, ['first', 'second']) adapter.serializeSummary = true diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index 12faf98360..9f2ca6ce41 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team-profile/README.md -README.md: 2d94b4d1c0e7de9079ac28945f1e79594b17bb76 -README.zh.md: b1126a266c472d5548b0adbc4139d701ac5b4f6f +README.md: 053f44b673552e7bb7d63ed944652ea6fb87145a +README.zh.md: bd6837339298fc13908e077833f75aa9c3a6696e diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 2d94b4d1c0..053f44b673 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -48,7 +48,7 @@ In an existing profile’s `package.json`, keep `@deepseek-ai/dsh-experimental-a The layer adds the Agent Teams domain and its scoped creation, roster, messaging, interruption, waiting, and task-board tools. Direct delegation uses `spawn_teammate`, which supports fresh and fork context. The `subagent` and `subagent_fork` tools and overlapping global child controls are disabled. Workflow retains the base profile’s `spawn` provider, while the underlying Subagent services and both providers remain available to teammates and workflow. -In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI. +In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI. The Plugins page reads the bundle's [icon](icon.svg) from its `package.json.icon` declaration, including while the bundle is disabled. ----- diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index b1126a266c..bd68373392 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -48,7 +48,7 @@ dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile 本层会添加 Agent Teams domain,以及 Team-scoped 创建、roster、消息、interrupt、等待与任务板工具。直接委派使用支持 fresh 和 fork 上下文的 `spawn_teammate`。`subagent`、`subagent_fork` 工具和名称重叠的全局 child control 均被禁用。Workflow 保留 base profile 的 `spawn` 提供方,底层 Subagent 服务和两个提供方仍供 teammate 与 workflow 使用。 -在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。 +在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。插件页通过组合包的 `package.json.icon` 声明读取其[图标](icon.svg),组合包禁用时也会显示。 ----- diff --git a/packages/experimental/agent-team-profile/icon.svg b/packages/experimental/agent-team-profile/icon.svg new file mode 100644 index 0000000000..488848231e --- /dev/null +++ b/packages/experimental/agent-team-profile/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 73999ee5d4..fcc06fcf37 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -2,6 +2,7 @@ "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Agent Teams collaboration, tools, and Web UI in one experimental bundle", "version": "0.1.6-alpha.2", + "icon": "./icon.svg", "publishConfig": { "access": "public" }, @@ -24,6 +25,7 @@ "./package.json": "./package.json" }, "files": [ + "icon.svg", "locale/*.json", "lib/index.js", "cordis.patch.yml", diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index f9a1ceacc4..f15da4a935 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -17,11 +17,18 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { renderPrompt, renderContextSnapshot } from '@deepseek-ai/dsh-system-prompt' import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { serializeRequest } from '@deepseek-ai/dsh-llm-deepseek/src/protocols/chat-completions/serialize.ts' +import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import { serialize } from '@deepseek-ai/dsh-llm-deepseek/src/serialize.ts' +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import TeamService from '../../agent-team/src/index.ts' import * as toolTeam from '../src/index.ts' +function serializeRequest(request: GenerateOptions) { + const connection = resolveAdapterOptions({ models: [{ id: request.model, systemPromptUpdate: 'in-history' }] }) + return serialize(request, connection, request.messages, new Map(), () => undefined) +} + const SIGNAL = new AbortController().signal const TOOL_NAMES = [ 'spawn_teammate', @@ -263,18 +270,18 @@ describe('dsh-tool-team', () => { await waitNoAgent(ctx, childId) const childRequest = serializeRequest(adapter.requests[1]!) expect(childRequest.tools).toEqual(parentRequest.tools) - expect(childRequest.messages[0]).toEqual(parentRequest.messages[0]) - expect(JSON.stringify(childRequest.messages[0])).not.toContain('Your Team role') + expect(childRequest.system).toEqual(parentRequest.system) + expect(childRequest.system).not.toContain('Your Team role') if (mode === 'fork') { expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages) expect(adapter.requests[1]!.messages.slice(0, parentHistory.length)).toEqual(parentHistory) } else { expect(JSON.stringify(childRequest.messages)).not.toContain('Parent task') } - expect(childRequest.messages).toContainEqual({ - role: 'user', - content: '\nYou are teammate "reviewer".\n\n\nReview the work', - }) + expect(childRequest.messages.at(-1)?.content.slice(0, 2)).toEqual([ + { type: 'text', text: '\nYou are teammate "reviewer".\n\n\n' }, + { type: 'text', text: 'Review the work' }, + ]) await using persisted = await ctx.sessionPersistence.open(childId, 'read') const { events } = await persisted.read() const initial = events.findLast(event => event.type === 'user/message' && event.data.source.kind === 'user') @@ -302,7 +309,7 @@ describe('dsh-tool-team', () => { const childRequest = serializeRequest(adapter.requests[1]!) expect(childRequest.tools).toEqual(parentRequest.tools) expect(childRequest.messages.slice(0, parentRequest.messages.length)).toEqual(parentRequest.messages) - expect(childRequest.messages.at(-1)?.content).toBe('Continue independently') + expect(childRequest.messages.at(-1)?.content).toContainEqual({ type: 'text', text: 'Continue independently' }) expect(JSON.stringify(childRequest.messages)).not.toContain('system-reminder') expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) expect(ctx.agentTeams.listMembers(handle.agent).map(member => member.name)).toEqual(['lead']) @@ -336,7 +343,7 @@ describe('dsh-tool-team', () => { expect(fork.tools).toEqual(original.tools) expect(fork.messages.slice(0, original.messages.length)).toEqual(original.messages) expect(forkRequest.messages.slice(0, inheritedHistory.length)).toEqual(inheritedHistory) - expect(fork.messages).toContainEqual({ role: 'user', content: 'Continue independently' }) + expect(fork.messages.at(-1)?.content).toContainEqual({ type: 'text', text: 'Continue independently' }) expect(JSON.stringify(fork.messages)).not.toContain('You are the Team Lead') expect(handle.agent.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) } finally { @@ -372,7 +379,7 @@ describe('dsh-tool-team', () => { await waitNoAgent(ctx, childId) const reminder = '\nYou are teammate "reviewer".\n\n\n' const first = serializeRequest(adapter.requests[0]!).messages - expect(first).toContainEqual({ role: 'user', content: `${reminder}Review the work` }) + expect(first.at(-1)?.content.slice(0, 2)).toEqual([{ type: 'text', text: reminder }, { type: 'text', text: 'Review the work' }]) expect(adapter.requests[1]!.messages.filter(message => message.content.some(block => block.type === 'text' && block.text === reminder))).toHaveLength(1) expect(JSON.stringify(serializeRequest(adapter.requests[2]!).messages)).not.toContain('You are teammate') @@ -411,8 +418,10 @@ describe('dsh-tool-team', () => { }) expect(spawned.isError, text(spawned)).toBe(false) await waitNoAgent(ctx, spawnedChildId(ctx, lead, spawned)) - expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content) - .toBe('\nYou are teammate "reviewer".\n\n\nReview the work') + expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content).toEqual([ + { type: 'text', text: '\nYou are teammate "reviewer".\n\n\n' }, + { type: 'text', text: 'Review the work' }, + ]) }) it('returns actionable no-progress output and renders structured wait cancellation', async () => { diff --git a/packages/experimental/webworker-packer/tests/preview.spec.ts b/packages/experimental/webworker-packer/tests/preview.spec.ts index 2c5c381059..f3b5937ab9 100644 --- a/packages/experimental/webworker-packer/tests/preview.spec.ts +++ b/packages/experimental/webworker-packer/tests/preview.spec.ts @@ -65,7 +65,7 @@ describe('Node preparation of Preview Session data', () => { expect(packVfsOverlay(fixture.trees).files).toEqual(original.files) }) - it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('adds catalog facts only for supported direct subagent children in the same root: %s', (mode) => { + it.each(['missing-descriptor', 'other-root', 'other-origin', 'current-child'] as const)('catalogs direct subagent children in the same root, retaining missing descriptors as unknown: %s', (mode) => { const fixture = source() const parent = fixture.put(3) const childDirectory = join(fixture.trees[0]!.directory, 'sessions', mode === 'other-root' ? 'other' : 'project', 'child') @@ -84,8 +84,12 @@ describe('Node preparation of Preview Session data', () => { const packed = packPreviewFixture(fixture.trees) const restored = restore(packed.files[`home/sessions/project/example/${sessionFormatLogFilename(currentVersion)}`]) expect(restored.inheritedEventCount).toBe(0) - expect(restored.events).toEqual(mode === 'current-child' ? [{ type: 'subagent/catalog', seq: 0, time: 1, - data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' } }] : []) + const catalog = mode === 'current-child' + ? [{ version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot' }] + : mode === 'missing-descriptor' + ? [{ version: 1, childId: 'child', childCreatedAt: 2, mode: 'unknown' }] + : [] + expect(restored.events).toEqual(catalog.map(data => ({ type: 'subagent/catalog', seq: 0, time: 1, data }))) for (const [path, bytes] of Object.entries(original.files)) expect(packed.files[path], path).toEqual(bytes) expect(readFileSync(parent)).toEqual(before) expect(readFileSync(childPath)).toEqual(childBefore) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 76a69a3cdf..b540e06b33 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1988,6 +1988,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'settings.section\' (client-ui-settings-general), so it exists while that entry is mounted', occupants: [ 'client-locale LanguageRow id \'language\'', + 'client-ui-chat LinkOpeningRow id \'link-opening\'', 'client-ui-chat PerformanceUsageRow id \'performance-usage\'', 'client-ui-chat TranscriptViewRow id \'transcript-view\'', 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2bedbd1775..39d048d95b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5358,7 +5358,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PluginLocalizedMeta', - declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly error?: string;\n}', + declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly icon?: string;\n readonly error?: string;\n}', }, { name: 'PluginRegistries', @@ -5734,7 +5734,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionAddress', - declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\';\n};', + declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\' | \'unknown\';\n};', }, { name: 'SessionAssistantStreamAttempt', @@ -6426,7 +6426,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentCatalogEntry', - declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n readonly external?: ExternalSubagentStatus;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n});', + declaration: 'export type SubagentCatalogEntry = {\n readonly id: SessionId;\n readonly createdAt: number;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n readonly external?: ExternalSubagentStatus;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n} | {\n readonly mode: \'unknown\';\n readonly label?: string;\n});', }, { name: 'SubagentCatalogRow', diff --git a/packages/llm/README.i18n.yaml b/packages/llm/README.i18n.yaml index f98e218e74..8b480a856c 100644 --- a/packages/llm/README.i18n.yaml +++ b/packages/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/README.md -README.md: 9f7e8f444345bb60677a57679ad559f1e06be83f -README.zh.md: fbb27ed1567fa3319a7fc1bdf61b6436113e0aba +README.md: cbd9e386f0f219eea4f41470bd5da076db00ebe0 +README.zh.md: a3a31dfddd38a07ac95aae037f157ac12763974e diff --git a/packages/llm/README.md b/packages/llm/README.md index 9f7e8f4443..cbd9e386f0 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -25,7 +25,7 @@ The llm group provides the harness's model-call capability: one provider-neutral | Package | Role | ctx key | |---|---|---| | [`llm/`](llm/README.md) | Streams one model call through a registered provider adapter and shares the harness message, block, and chunk vocabulary | `ctx.llm` | -| [`llm-deepseek/`](llm-deepseek/README.md) | Serves the `deepseek-official` route with direct DeepSeek chat-completions, thinking, and image input | registers on `ctx.llm` | +| [`llm-deepseek/`](llm-deepseek/README.md) | Serves the `deepseek-official` route through DeepSeek Messages with thinking and image input | registers on `ctx.llm` | | [`llm-pi-ai/`](llm-pi-ai/README.md) | Serves configured provider routes through pi-ai catalogs and wire protocols, including hand-declared gateways | registers on `ctx.llm` | | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.md) | Registers lifecycle-owned top-level fields on official DeepSeek requests | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.md) | Contributes the active Loader package inventory to official DeepSeek requests | contributes `dsh_plugin_packages` | diff --git a/packages/llm/README.zh.md b/packages/llm/README.zh.md index fbb27ed156..a3a31dfddd 100644 --- a/packages/llm/README.zh.md +++ b/packages/llm/README.zh.md @@ -25,7 +25,7 @@ llm 组提供 harness 的模型调用能力:一个提供方无关的服务, | 包 | 职责 | ctx key | |---|---|---| | [`llm/`](llm/README.zh.md) | 通过已注册的提供方适配器流式发起一次模型调用,并共享 harness 的消息、块与分片词汇 | `ctx.llm` | -| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 以 DeepSeek chat-completions 直连、thinking 与图片输入服务 `deepseek-official` 路由 | 注册到 `ctx.llm` | +| [`llm-deepseek/`](llm-deepseek/README.zh.md) | 通过 DeepSeek Messages 为 `deepseek-official` 路由提供思考与图片输入 | 注册到 `ctx.llm` | | [`llm-pi-ai/`](llm-pi-ai/README.zh.md) | 通过 pi-ai 目录与协议格式服务配置的提供方路由,包括手工声明的网关 | 注册到 `ctx.llm` | | [`deepseek-llm-api-extensions/`](deepseek-llm-api-extensions/README.zh.md) | 在官方 DeepSeek 请求上注册具有生命周期归属的顶层字段 | `ctx.deepseekLlmApiExtensions` | | [`plugin-package-inventory-deepseek/`](plugin-package-inventory-deepseek/README.zh.md) | 为官方 DeepSeek 请求贡献当前启用的 Loader 包清单 | 贡献 `dsh_plugin_packages` | diff --git a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml index caa80e03ad..44a416eea5 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml +++ b/packages/llm/deepseek-llm-api-extensions/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/deepseek-llm-api-extensions/README.md -README.md: 45a4182d77eb293b55d39d043bf8a1e1075873cf -README.zh.md: 0131791ca5837d3143c17640bf10cb1f4d215555 +README.md: 1505c644a32a8c90c38720ac6229eb3b654693a1 +README.zh.md: d27eca175c19bb914045958d1d748f427a4971b7 diff --git a/packages/llm/deepseek-llm-api-extensions/README.md b/packages/llm/deepseek-llm-api-extensions/README.md index 45a4182d77..1505c644a3 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.md +++ b/packages/llm/deepseek-llm-api-extensions/README.md @@ -27,7 +27,7 @@ Provider-specific registry for additive top-level fields on official DeepSeek LL - `prepare(request)` snapshots the registered providers, prepares them concurrently, clones and freezes returned JSON values, and returns `{ fields, accept }`. A preparation failure rejects before HTTP dispatch; request cancellation stops awaiting providers even when one ignores its signal. - `accept()` runs every captured post-2xx callback once. Concurrent calls join the same settlement, every callback settles before failures are reported, and several failures become one `AggregateError`. -Both Chat Completions and Messages prepare these fields. Each provider sees the exact serialized base body in the selected protocol, the request `AbortSignal`, plus optional `sessionId` and auxiliary-call `purpose`. It must stop its own work promptly after cancellation and returns `undefined` when its field does not apply to that request. A prepared operation retains the providers it captured even if HMR removes their registrations before HTTP acceptance. +Each provider sees the exact serialized Messages body, the request `AbortSignal`, plus optional `sessionId` and auxiliary-call `purpose`. It must stop its own work promptly after cancellation and returns `undefined` when its field does not apply to that request. A prepared operation retains the providers it captured even if HMR removes their registrations before HTTP acceptance. The registry owns addition and lifecycle, not field semantics. `@deepseek-ai/dsh-session-log-deepseek` owns `dsh_session_log`; `@deepseek-ai/dsh-plugin-package-inventory-deepseek` owns `dsh_plugin_packages`. The provider-neutral LLM seam and `llm-pi-ai` do not consume this registry. diff --git a/packages/llm/deepseek-llm-api-extensions/README.zh.md b/packages/llm/deepseek-llm-api-extensions/README.zh.md index 0131791ca5..d27eca175c 100644 --- a/packages/llm/deepseek-llm-api-extensions/README.zh.md +++ b/packages/llm/deepseek-llm-api-extensions/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" - `prepare(request)` 对已注册提供方取快照,并发准备贡献,克隆并冻结返回的 JSON 值,然后返回 `{ fields, accept }`。准备失败会在 HTTP 分发前拒绝请求;请求取消后,即使某个提供方忽略信号,注册表也会停止等待。 - `accept()` 对每个捕获的 2xx 后回调只运行一次。并发调用会等待同一次结算,所有回调都在报告失败前完成,多个失败会合并为一个 `AggregateError`。 -Chat Completions 和 Messages 都会准备这些字段。每个提供方都会看到所选协议中确切的已序列化基础请求体、请求 `AbortSignal`,以及可选的 `sessionId` 与辅助调用 `purpose`。提供方必须在取消后迅速停止自身工作;字段不适用于当前请求时返回 `undefined`。即使 HMR(热模块替换)在 HTTP 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。 +每个提供方都会看到确切的已序列化 Messages 请求体、请求 `AbortSignal`,以及可选的 `sessionId` 与辅助调用 `purpose`。提供方必须在取消后迅速停止自身工作;字段不适用于当前请求时返回 `undefined`。即使 HMR(热模块替换)在 HTTP 接受前移除了注册,已准备的操作仍会保留其捕获的提供方。 注册表拥有字段添加与生命周期,不拥有字段语义。`@deepseek-ai/dsh-session-log-deepseek` 拥有 `dsh_session_log`;`@deepseek-ai/dsh-plugin-package-inventory-deepseek` 拥有 `dsh_plugin_packages`。提供方无关的 LLM seam 与 `llm-pi-ai` 都不消费该注册表。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index bcc2683e92..ac07d1a93c 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 5bad4d36296c350ef2bc88ad2ff58fec33b93805 -README.zh.md: 1c1c5a8255c3cd894e0aeecfe49079a78fd03eeb +README.md: e03b1024bf83fa39603c3c2413c3270df70b7bda +README.zh.md: 9f14e175d95ff0cac3e77c972a9bbc940c9d9e9f diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5bad4d3629..e03b1024bf 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -1,5 +1,5 @@ --- -description: "Configure DeepSeek Messages, Chat Completions overrides, reasoning, and image input through one first-party provider." +description: "Configure DeepSeek Messages, reasoning, and image input." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Stream DeepSeek models through `deepseek-official` with Messages by default, or select Chat Completions in Cordis YAML. Both protocols share credentials, endpoint settings, image handling, and the model catalog. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. Web shows one DeepSeek provider with an editable API base and key. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md). +Stream DeepSeek models through `deepseek-official` using the Messages API. Configure the endpoint, credentials, reasoning, and image handling from Cordis YAML or Web settings. Valid settings changes affect subsequent calls while in-flight calls retain their configuration. This package can run beside the [pi-ai adapter](../llm-pi-ai/README.md). ## Table of Contents @@ -27,11 +27,11 @@ Stream DeepSeek models through `deepseek-official` with Messages by default, or Mount this plugin when a composition streams DeepSeek models through the harness LLM service. It registers the single `deepseek-official` route and resolves connection facts per request, so a composition entry plus an optional user settings section drive the whole adapter. -Both protocols accept the LLM service's [request-only user inputs](../llm/README.md#use-this-package) alongside durable history; omitting request-only identity and attribution does not alter provider content. +The adapter accepts the LLM service's [request-only user inputs](../llm/README.md#use-this-package) alongside durable history; omitting request-only identity and attribution does not alter provider content. ### When to choose it -Choose this adapter for DeepSeek's official API or a gateway that supports the selected protocol through `baseURL`. Choose `dsh-llm-pi-ai` when the same composition also routes other providers or hand-declared gateways through pi-ai's catalogs; the two adapters can be mounted together because their route names do not collide. Registering any other adapter for `deepseek-official` fails with `DUPLICATE_ADAPTER`. +Choose this adapter for DeepSeek's official API or a Messages-compatible gateway through `baseURL`. Choose `dsh-llm-pi-ai` when the same composition also routes other providers or hand-declared gateways through pi-ai's catalogs; the two adapters can be mounted together because their route names do not collide. Registering any other adapter for `deepseek-official` fails with `DUPLICATE_ADAPTER`. ### Minimal configuration @@ -51,9 +51,8 @@ A request selects the route with `provider: deepseek-official`; the model id pas | Field | Default | Meaning | |---|---|---| -| `protocol` | `messages` | Choose `messages` or `chat-completions` in Cordis YAML; Web has no protocol selector | | `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved per request through the credentials seam, then the environment | -| `baseURL` | Selected protocol’s official root | Explicit value, then `$DEEPSEEK_BASE_URL`, then the selected protocol default | +| `baseURL` | `https://api.deepseek.com/anthropic` | Explicit value, then `$DEEPSEEK_BASE_URL`, then the official root | | `thinking` | `enabled` | Deployment policy; `disabled` locks every request to `off` | | `reasoningEffort` | `high` | Default effort: `off`, `low`, `high`, or `max` | | `maxTokens` | `256,000` | Per-request output cap; a model's own cap and explicit request values win | @@ -76,32 +75,24 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a When [proactive compaction](../../compaction/compaction-basic/README.md#use-this-package) is enabled, `models[].contextWindow` (or `defaultContextWindow` when absent) must exceed the effective request `maxTokens` plus the compaction policy’s `headroomTokens`. Requests without an explicit output cap use the model’s `maxTokens` or the adapter default. For small-window deployments, configure headroom within that capacity; lower `thresholdRatio` to compact earlier. - -### Choose a protocol + +### Endpoint and wire format -To select Chat Completions explicitly, patch the existing plugin: +The official root is `https://api.deepseek.com/anthropic`. An explicit `baseURL` or `$DEEPSEEK_BASE_URL` supplies a Messages-compatible root. Model and Files requests append `/v1/messages` and `/v1/files`, except that an exact final `/v1` segment is reused. Trailing slashes do not change these results. A base URL must use HTTP(S) without credentials, query, or fragment. -```yaml -- id: llm-deepseek - config: - protocol: chat-completions -``` - -`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions inherit this default. Neither protocol requires `baseURL`: its official default applies when both `baseURL` and `$DEEPSEEK_BASE_URL` are absent. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. An explicit `https://api.deepseek.com` override selects the Chat root: remove that override to use the official Messages default, or set it to `https://api.deepseek.com/anthropic`. Chat appends `/chat/completions`. Messages and its Files API treat only an exact final `/v1` path segment as the existing Anthropic API version and append `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The official Messages root therefore retains its recommended `/anthropic/v1` request paths without granting compatibility to arbitrary version-like suffixes. Trailing slashes do not change these results. Both protocols share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid. - -Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata identifies the Messages format, model, and signatures. Chat requests serialize durable content without those signatures. Invalid Messages replay metadata emits a warning and omits signatures while retaining text and tool history. +Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata preserves the model and thinking signatures. Invalid replay metadata emits a warning and omits signatures while retaining text and tool history. ### Streaming with thinking and images An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. -Both protocols normally upload those exact request bytes through their DeepSeek Files endpoint and send file-id references. Messages uses `/v1/files` under its configured base and includes `anthropic-beta: files-api-2025-04-14` on Files requests and Messages requests containing file ids; Chat uses `/files`. Messages model requests and all Files requests reject redirects so credentials remain on the configured origin. A failed or timed-out file resolution rebuilds the whole model request with inline base64 under the inline budget; one request never mixes file ids and inline images. Caller cancellation stops the request. +The adapter normally uploads those exact request bytes through `/v1/files` and sends file-id references. Files requests and model requests containing file ids include `anthropic-beta: files-api-2025-04-14`. All requests reject redirects so credentials remain on the configured origin. A failed or timed-out file resolution rebuilds the whole model request with inline base64 under the inline budget; one request never mixes file ids and inline images. Caller cancellation stops the request. -Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Both uploads request expiry through `expires_after[anchor]=created_at` and `expires_after[seconds]`. Messages file metadata omits remote expiry, so its local reuse deadline uses the original upload time plus `fileExpiresAfterSeconds`; this does not guarantee remote deletion. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. +Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Uploads request expiry through `expires_after[anchor]=created_at` and `expires_after[seconds]`. Messages file metadata omits remote expiry, so its local reuse deadline uses the original upload time plus `fileExpiresAfterSeconds`; this does not guarantee remote deletion. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. Files mode bounds retained request versions by `maxRequestFilesBytes` and `maxImagesPerRequest`; inline fallback has its own base64 budget. Both remove an oldest prefix in configured byte or count quanta. Each omitted image gets its own model-visible placeholder with its display name or attachment id and, when available, normalized dimensions, media type, and current read-only path. The stepped high-watermark policy avoids rewriting an old request prefix after every new image. -`reasoningEffort` selects the advertised default. Exact-model metadata exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance when deployment policy permits thinking. `low`, `high`, and `max` enable thinking and serialize as `reasoning_effort` for Chat Completions or `output_config.effort` for Messages, while adapter-owned `off` sends `thinking.type: disabled` instead. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O, and `thinking: disabled` rejects any non-`off` effort at plugin load. Requests with `purpose: 'session-title'` force thinking off to reserve output for visible title text. Both protocols forward an explicit `temperature`; DeepSeek accepts it with thinking enabled but ignores its value in that mode. +`reasoningEffort` selects the advertised default. Exact-model metadata exposes ordered `off`, `low`, `high`, and `max` efforts with selection guidance when deployment policy permits thinking. `low`, `high`, and `max` enable thinking and serialize as `output_config.effort`, while adapter-owned `off` sends `thinking.type: disabled` instead. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O, and `thinking: disabled` rejects any non-`off` effort at plugin load. Requests with `purpose: 'session-title'` force thinking off to reserve output for visible title text. The adapter forwards an explicit `temperature`; DeepSeek accepts it with thinking enabled but ignores its value in that mode. ### Dynamic configuration @@ -109,10 +100,12 @@ Connection facts are re-read once per operation through the optional settings an ### Provider-specific request fields -For either protocol, when `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. Shipped compositions use this for the default-on incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input. +When `ctx.deepseekLlmApiExtensions` is present, the adapter prepares its registered top-level fields from the exact serialized base request before `fetch`. Preparation or field collisions fail before HTTP; after a 2xx response, the adapter accepts every captured contribution before consuming SSE. Transport and non-2xx failures do not accept them. Shipped compositions use this for the default-on incremental `dsh_session_log` field and the default-on active `dsh_plugin_packages` inventory; both stay outside model input. ### Failures and recovery +Configuration accepts Messages only and has no `protocol` field. If resolution reports `protocol is not configurable`, remove `protocol` from the `llm-deepseek` section in `/settings.yaml` and from any matching Cordis entry or overlay. Keep the intended `baseURL`, `apiKeyEnv`, and `models` fields. An invalid live section keeps the complete last good configuration; saving other fields in the Models card does not remove an unknown property. Edit the configuration file, then let settings reload or restart the profile; composition edits require a profile restart. + Successful Files responses must contain valid JSON. JSON decoding failures from upload, list, retrieve, and delete throw `INVALID_RESPONSE` with the operation and HTTP status in the message, the status in `LlmError.failure`, and the original parser error as `cause`. Body-read transport and cancellation errors retain their identity. Non-2xx responses fail with stable codes: `AUTH` (401/403), `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, and `HTTP_` otherwise; pre-response transport failures throw `TRANSPORT`, caller aborts throw `ABORTED`, and stream-idle expiry throws `TIMEOUT`. Request-extension preparation, field collision, or post-2xx acceptance fails with `REQUEST_EXTENSION`. A normalized-image rejection names every plausible attachment and its durable position when the provider does not identify a file id. Stale-file rejection invalidates the named mappings (or every mapping used by the attempt) and permits one replacement model request. Protocol violations throw `STREAM_CLOSED` or `MALFORMED_RESPONSE`, and a terminal `stop` with no content blocks becomes `EMPTY_RESPONSE`, which the default retry policy retries. A request with no key anywhere fails with `MISSING_CREDENTIAL`, and a malformed credential fails with `INVALID_CREDENTIAL` naming the reference to fix — never any part of the key. @@ -133,17 +126,7 @@ The plugin is built on one explicit resolve step and one registration fact. `res ### Source map -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Settings, credentials, and provider registration | -| [`src/config.ts`](src/config.ts) | Schema and request-local configuration resolution | -| [`src/adapter.ts`](src/adapter.ts) | Protocol dispatch with frozen prepared-call configuration | -| [`src/common/models.ts`](src/common/models.ts) | Shared model catalog | -| [`src/common/model-info.ts`](src/common/model-info.ts) | Shared model capabilities and reasoning choices | -| [`src/common/file-store.ts`](src/common/file-store.ts) | Shared Files cache, refresh, quota cleanup, and cancellation | -| [`src/common/files-api.ts`](src/common/files-api.ts) | Protocol-specific Files endpoints and response mapping | -| [`src/protocols/chat-completions/adapter.ts`](src/protocols/chat-completions/adapter.ts) | Chat transport, image projection, and request extensions | -| [`src/protocols/messages/adapter.ts`](src/protocols/messages/adapter.ts) | Messages transport, image projection, request extensions, and native replay | +[`src/index.ts`](src/index.ts) registers the provider and resolves settings and credentials. [`src/adapter.ts`](src/adapter.ts) owns the request lifecycle; [`src/serialize.ts`](src/serialize.ts) and [`src/translate.ts`](src/translate.ts) map model input and streamed output. [`src/file-store.ts`](src/file-store.ts) owns upload reuse and recovery through [`src/files-api.ts`](src/files-api.ts). ### Wire flow @@ -177,11 +160,11 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. This silent fallback also applies after switching from Chat Completions. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results; omission of these two block types matches Chat Completions and the pi-ai adapter. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. #### Token effect -Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. Chat Completions uses `prompt_tokens + completion_tokens` and omits `totalTokens` if a supplied `total_tokens` disagrees. +Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published vision accounting (14px patch grid, 3:1 downsampling, 544×544 scale-up floor, 1024-token cap). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. Messages totals include uncached input, output, cache-read, and cache-write tokens. #### KV Cache effect @@ -203,7 +186,6 @@ Loop-retained response blocks append to the next request and preserve its earlie ## Known Limitations and Deferred Work -- Responses is not implemented; configuration rejects `responses`. @@ -213,23 +195,14 @@ These limits define where the adapter stops and future work begins. They are cur - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. - **`tool_choice` is not mapped** — not part of the core vocabulary (shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy or interception configuration. -- **Chat Completions skips plugin-added content block types** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. - **Messages in-history system updates require a retained user or tool-result turn** — if all user input after an update is omitted and the preceding wire turn is assistant, serialization fails with `UNSUPPORTED_CONTENT` before the next assistant or at the end of the request. Text or an empty tool result can retain that turn. Moving the update to an earlier turn is not supported; the [input-history decision](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md) records the ordering constraint. - **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery. -- The default catalog pre-registers `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. With `DEEPSEEK_API_KEY` and a supporting gateway configured, `DEEPSEEK_FLASH_E2E=1` enables the Chat Completions check in [this package's e2e suite](tests/adapter.e2e.ts). -- The [Messages system-update e2e checks](tests/messages/adapter.e2e.ts) require `DEEPSEEK_IN_HISTORY_MODEL` to name a supported model, such as `deepseek-flash`, and run with `high` effort. They skip when that variable is unset or empty; ordinary `off` text checks remain enabled with credentials. Known instruction-following instability with thinking disabled makes these system-update checks unsuitable for `off`. +- The default catalog advertises `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. +- Real API checks in [adapter.e2e.ts](tests/adapter.e2e.ts) and [runtime.e2e.ts](tests/runtime.e2e.ts) require `DEEPSEEK_API_KEY`. Set `DEEPSEEK_IN_HISTORY_MODEL` to a supported nonempty model id to run system-update checks: the adapter suite uses `high` effort, while the runtime suite compares cache reuse with thinking disabled and can be sensitive to instruction-following instability. The runtime image cases additionally require `DEEPSEEK_FLASH_E2E=1` or `DEEPSEEK_VISION_E2E=1`. ### Dev Note -
-Working context for maintainers — click to expand - -This Dev Note is non-authoritative working context: undecided directions and notes for maintainers. Shipped behavior and accepted rationale live in the sections above, the package code, and the linked Agent Notes. - -- OpenRouter-specific app attribution headers are deferred to a future explicit OpenRouter adapter or mode; OpenAI-compatible gateway requests carry only the shared attribution baseline. -- The `off` reasoning effort never crosses the wire as `reasoning_effort: 'off'`; it serializes as `thinking: { type: 'disabled' }` and omits the field, which keeps the wire spelling valid for gateways that reject unknown effort values. - -
+None. **Runtime invariant:** No companion is published. This package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1c1c5a8255..9f14e175d9 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -1,5 +1,5 @@ --- -description: "通过同一官方提供方配置 DeepSeek Messages、Chat Completions 覆盖、推理与图片输入。" +description: "配置 DeepSeek Messages、推理与图片输入。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -通过 `deepseek-official` 流式调用 DeepSeek 模型,默认使用 Messages,也可在 Cordis YAML 中选择 Chat Completions。两种协议共用凭据、端点配置、图片处理和模型目录。有效的设置更改在后续请求生效,进行中的请求保留原配置。Web 显示一个 DeepSeek 提供方,并提供 API 地址和密钥编辑。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。 +通过 `deepseek-official` 使用 Messages API 流式调用 DeepSeek 模型。在 Cordis YAML 或 Web 设置中配置端点、凭据、推理与图片处理。有效的设置更改在后续请求生效,进行中的请求保留原配置。本包可与 [pi-ai 适配器](../llm-pi-ai/README.zh.md)并用。 ## 目录 @@ -27,11 +27,11 @@ kind: "package-reference" 当组合需要通过 harness LLM(大语言模型)服务流式调用 DeepSeek 模型时挂载本插件。它注册唯一的 `deepseek-official` 路由,并按请求解析连接事实,因此组合条目加可选用户设置分节即可驱动整个适配器。 -两种协议都接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。 +适配器接受 LLM 服务的[仅供请求使用的 user 输入](../llm/README.zh.md#use-this-package),并可将其与持久历史混用;省略请求输入的身份与来源不会改变提供方内容。 ### 何时选择 -面向 DeepSeek 官方 API,或通过 `baseURL` 连接支持所选协议的网关时,选择本适配器。当同一组合还要通过 pi-ai 目录路由其他提供方或手工声明的网关时,选择 `dsh-llm-pi-ai`;两个适配器可以同时挂载,因为它们的路由名不冲突。为 `deepseek-official` 注册任何其他适配器会以 `DUPLICATE_ADAPTER` 失败。 +面向 DeepSeek 官方 API,或通过 `baseURL` 连接兼容 Messages 的网关时,选择本适配器。当同一组合还要通过 pi-ai 目录路由其他提供方或手工声明的网关时,选择 `dsh-llm-pi-ai`;两个适配器可以同时挂载,因为它们的路由名不冲突。为 `deepseek-official` 注册任何其他适配器会以 `DUPLICATE_ADAPTER` 失败。 ### 最小配置 @@ -51,9 +51,8 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `protocol` | `messages` | Cordis YAML 中选择 `messages` 或 `chat-completions`;Web 不提供选择器 | | `apiKeyEnv` | `DEEPSEEK_API_KEY` | 按请求解析的凭据引用:先经凭据 seam,再到环境变量 | -| `baseURL` | 按协议选择官方根地址 | 显式值优先,其次 `$DEEPSEEK_BASE_URL`,最后采用当前协议的官方端点 | +| `baseURL` | `https://api.deepseek.com/anthropic` | 显式值优先,其次为 `$DEEPSEEK_BASE_URL`,最后为官方根地址 | | `thinking` | `enabled` | 部署策略;`disabled` 把所有请求锁定为 `off` | | `reasoningEffort` | `high` | 默认强度:`off`、`low`、`high` 或 `max` | | `maxTokens` | `256,000` | 单次请求输出上限;模型自身上限与显式请求值优先 | @@ -76,32 +75,24 @@ kind: "package-reference" 启用[主动压缩](../../compaction/compaction-basic/README.zh.md#use-this-package)时,`models[].contextWindow`(未声明时使用 `defaultContextWindow`)必须大于生效请求的 `maxTokens` 与压缩策略 `headroomTokens` 之和。请求未覆盖输出上限时,使用模型的 `maxTokens` 或适配器默认值。小窗口部署应在容量范围内配置余量;降低 `thresholdRatio` 可以提早压缩。 - -### 选择协议 + +### 端点与协议格式 -通过补丁为已有插件显式选择 Chat Completions: +官方根地址为 `https://api.deepseek.com/anthropic`。显式 `baseURL` 或 `$DEEPSEEK_BASE_URL` 提供兼容 Messages 的根地址。模型与 Files 请求分别追加 `/v1/messages` 和 `/v1/files`,但末尾严格匹配的 `/v1` 路径段会直接复用。末尾斜线不改变这些结果。基址必须使用 HTTP(S),且不含凭据、查询或片段。 -```yaml -- id: llm-deepseek - config: - protocol: chat-completions -``` - -`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合继承该默认值。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。显式填写的 `https://api.deepseek.com` 是 Chat 根地址:删除该覆盖即可使用官方 Messages 默认值,也可以改填 `https://api.deepseek.com/anthropic`。Chat 追加 `/chat/completions`。Messages 与其 Files API 仅把末尾严格匹配的 `/v1` 路径段视为已有 Anthropic API 版本,并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。因此,官方 Messages 根地址仍使用推荐的 `/anthropic/v1` 请求路径,同时不为任意版本式后缀提供兼容性。末尾斜线不改变这些结果。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。 - -Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据记录 Messages 格式、模型和签名;Chat 请求只序列化持久化内容,不发送这些签名。无效的 Messages 回放元数据产生警告并省略签名,不丢弃文本或工具历史。 +Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据保留模型与思考签名。无效的回放元数据产生警告并省略签名,不丢弃文本或工具历史。 ### 带 thinking 与图片的流式调用 支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 -两种协议通常通过各自的 DeepSeek Files 端点上传这些确切请求字节,并发送 file-id 引用。Messages 在配置的基址下使用 `/v1/files`,Files 请求与包含 file id 的 Messages 请求均携带 `anthropic-beta: files-api-2025-04-14`;Chat 使用 `/files`。Messages 模型请求与所有 Files 请求拒绝重定向,确保凭据仅发送到配置的源。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。 +适配器通常通过 `/v1/files` 上传这些确切请求字节,并发送 file-id 引用。Files 请求与包含 file id 的模型请求均携带 `anthropic-beta: files-api-2025-04-14`。全部请求拒绝重定向,确保凭据仅发送到配置的源。文件解析失败或超时会按内联预算,用内联 base64 重建整份模型请求;一次请求绝不混用 file id 与内联图片。调用方取消会停止请求。 -缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。两种上传都通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 +缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。上传通过 `expires_after[anchor]=created_at` 与 `expires_after[seconds]` 请求过期。Messages 文件元数据不含远端过期时间,因此本地复用期限使用原始上传时间加 `fileExpiresAfterSeconds`;这不保证远端文件删除。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留请求版本;内联回退有独立 base64 预算。两种模式都按配置的字节或数量量子移除最旧前缀。每张省略图片都有自己的模型可见占位符,包含显示名或附件 id,以及可用时的规范化尺寸、媒体类型与当前只读路径。分阶高水位策略避免每新增一张图片都改写旧请求前缀。 -`reasoningEffort` 选择公布的默认值。当部署策略允许 thinking 时,确切模型元数据会按顺序公开 `off`、`low`、`high` 与 `max` 强度及选择指引。`low`、`high` 与 `max` 启用 thinking,在 Chat Completions 中以 `reasoning_effort` 序列化,在 Messages 中以 `output_config.effort` 序列化,适配器自有的 `off` 则发送 `thinking.type: disabled`。不支持的取值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败;`thinking: disabled` 会在插件加载时拒绝任何非 `off` 强度。`purpose: 'session-title'` 的请求会强制关闭 thinking,把有界输出留给可见标题文本。两种协议都转发显式 `temperature`;DeepSeek 在启用 thinking 时接受该参数,但忽略其值。 +`reasoningEffort` 选择公布的默认值。当部署策略允许 thinking 时,确切模型元数据会按顺序公开 `off`、`low`、`high` 与 `max` 强度及选择指引。`low`、`high` 与 `max` 启用 thinking,并以 `output_config.effort` 序列化,适配器自有的 `off` 则发送 `thinking.type: disabled`。不支持的取值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败;`thinking: disabled` 会在插件加载时拒绝任何非 `off` 强度。`purpose: 'session-title'` 的请求会强制关闭 thinking,把有界输出留给可见标题文本。适配器转发显式 `temperature`;DeepSeek 在启用 thinking 时接受该参数,但忽略其值。 ### 动态配置 @@ -109,10 +100,12 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 ### 提供方专用请求字段 -两种协议中,存在 `ctx.deepseekLlmApiExtensions` 时,适配器都会在 `fetch` 前根据确切序列化基础请求准备已注册顶层字段。准备或字段冲突在 HTTP 前失败;2xx 响应后,适配器会在消费 SSE(Server-Sent Events)前接受每项已捕获贡献。传输与非 2xx 失败不会接受它们。随产品交付的组合用它提供默认启用的增量 `dsh_session_log` 字段和默认启用的活跃 `dsh_plugin_packages` 清单;两者都留在模型输入之外。 +存在 `ctx.deepseekLlmApiExtensions` 时,适配器会在 `fetch` 前根据确切序列化基础请求准备已注册顶层字段。准备或字段冲突在 HTTP 前失败;2xx 响应后,适配器会在消费 SSE(Server-Sent Events)前接受每项已捕获贡献。传输与非 2xx 失败不会接受它们。随产品交付的组合用它提供默认启用的增量 `dsh_session_log` 字段和默认启用的活跃 `dsh_plugin_packages` 清单;两者都留在模型输入之外。 ### 失败与恢复 +配置仅接受 Messages,不提供 `protocol` 字段。若解析报告 `protocol is not configurable`,请从 `/settings.yaml` 的 `llm-deepseek` 分节以及对应 Cordis 条目或 overlay 中删除 `protocol`,保留需要的 `baseURL`、`apiKeyEnv` 和 `models` 字段。无效的实时分节会继续使用完整的最后有效配置;在模型设置卡中保存其他字段不会移除未知属性。请编辑配置文件,等待设置重新加载或重启 profile;组装配置变更需要重启 profile。 + 成功的 Files 响应必须包含有效 JSON。上传、列举、获取和删除操作的 JSON 解码失败抛出 `INVALID_RESPONSE`,消息包含操作名称与 HTTP 状态,`LlmError.failure` 保留该状态,`cause` 保留原始解析错误。读取响应体时的传输和取消错误保留其原有身份。 非 2xx 响应以稳定 code 失败:`AUTH`(401/403)、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER` 以及其他情况的 `HTTP_`;响应前传输失败抛出 `TRANSPORT`,调用方中止抛出 `ABORTED`,流空闲超时抛出 `TIMEOUT`。请求扩展准备、字段冲突或 2xx 后接受失败使用 `REQUEST_EXTENSION`。当提供方未指出 file id 时,规范化图片拒绝会列出所有可能附件及其持久位置。陈旧文件拒绝会使点名映射(或该次尝试使用的全部映射)失效,并允许一次替换模型请求。协议违规抛出 `STREAM_CLOSED` 或 `MALFORMED_RESPONSE`;不带内容块的终止 `stop` 变成 `EMPTY_RESPONSE`,默认重试策略会重试它。任何位置都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;格式错误的凭据以 `INVALID_CREDENTIAL` 失败,并点名需要修复的引用——绝不包含密钥的任何部分。 @@ -131,19 +124,9 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 插件建立在一个显式解析步骤与一条注册事实之上。`resolveAdapterOptions()` 是从原始配置到已校验连接事实的唯一路径,适配器通过 thunk 每次操作重新读取这些事实——基址、目录、请求默认值、图片与 Files 策略及空闲预算都会作用于下一个请求,而进行中的流保持其启动时的事实。注册时捕获的唯一事实是重试策略:解析值变化时,插件会在一个同步区段内原位重新注册路由,因此任何请求都观察不到空档。 -### 源码地图 +### 源码导航 -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | settings、凭据与提供方注册 | -| [`src/config.ts`](src/config.ts) | schema 与请求配置解析 | -| [`src/adapter.ts`](src/adapter.ts) | 按协议分派,并冻结已准备请求的配置 | -| [`src/common/models.ts`](src/common/models.ts) | 共享模型目录 | -| [`src/common/model-info.ts`](src/common/model-info.ts) | 共享模型能力与推理选项 | -| [`src/common/file-store.ts`](src/common/file-store.ts) | 共享 Files 缓存、刷新、配额清理与取消 | -| [`src/common/files-api.ts`](src/common/files-api.ts) | 各协议的 Files 端点与响应映射 | -| [`src/protocols/chat-completions/adapter.ts`](src/protocols/chat-completions/adapter.ts) | Chat 传输、图片投影与请求扩展 | -| [`src/protocols/messages/adapter.ts`](src/protocols/messages/adapter.ts) | Messages 传输、图片投影、请求扩展与原生回放 | +[`src/index.ts`](src/index.ts) 注册提供方并解析设置与凭据。[`src/adapter.ts`](src/adapter.ts) 管理请求生命周期;[`src/serialize.ts`](src/serialize.ts) 和 [`src/translate.ts`](src/translate.ts) 映射模型输入与流式输出。[`src/file-store.ts`](src/file-store.ts) 通过 [`src/files-api.ts`](src/files-api.ts) 管理上传复用与恢复。 ### 协议流程 @@ -177,11 +160,11 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。从 Chat Completions 切换后也适用此静默兜底。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块;对这两类块的省略行为与 Chat Completions 和 pi-ai 适配器一致。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 #### Token 影响 -提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。Chat Completions 使用 `prompt_tokens + completion_tokens`,提供方给出的 `total_tokens` 不一致时省略 `totalTokens`。 +提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的视觉计量规则(14 px patch 网格、3:1 降采样、544×544 放大下限、单图 1024 token 上限)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。Messages 的 token 总数包含未缓存输入、输出、缓存读取与缓存写入 token。 #### KV Cache 影响 @@ -203,7 +186,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 ## 已知限制与延期工作 -- Responses 协议尚未实现,配置值 `responses` 会被拒绝。 @@ -213,23 +195,14 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 - **设置中的 `models` 列表会整体替换组合列表**——设置层按字段合并,数组只算一个字段;按条目合并目录需要带键的形状。 - **不映射 `tool_choice`**——不属于核心词汇(与 pi-ai 孪生共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**——没有共享代理或拦截配置。 -- **Chat Completions 跳过插件新增的内容块类型**——核心文本与受支持图片块会被序列化,空工具输出以字面量 `(no output)` 过线。 - **Messages 历史内 system 更新需要保留用户或工具结果轮次**——若更新后的全部用户输入都被省略,且前一个协议轮次是 assistant,序列化会在下一个 assistant 之前或请求结束处以 `UNSUPPORTED_CONTENT` 失败。文本或空工具结果可以保留该轮次。不支持将更新移到更早的轮次;[输入历史决策](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)记录了排序约束。 - **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。 -- 默认目录预注册 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。配置 `DEEPSEEK_API_KEY` 和支持该 ID 的网关后,设置 `DEEPSEEK_FLASH_E2E=1` 可启用[本包 e2e 测试文件](tests/adapter.e2e.ts)中的 Chat Completions 协议验证。 -- [Messages system 更新 e2e](tests/messages/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。 +- 默认目录公布 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。 +- [adapter.e2e.ts](tests/adapter.e2e.ts) 与 [runtime.e2e.ts](tests/runtime.e2e.ts) 中的真实 API 检查需要 `DEEPSEEK_API_KEY`。将 `DEEPSEEK_IN_HISTORY_MODEL` 设为受支持的非空模型 ID 可运行 system 更新检查:适配器套件使用 `high` 思考强度,运行时套件则在关闭思考时比较缓存复用,可能受到指令遵循不稳定的影响。运行时图片用例还需要 `DEEPSEEK_FLASH_E2E=1` 或 `DEEPSEEK_VISION_E2E=1`。 ### 开发备注 -
-维护者的工作上下文——点击展开 - -本开发备注是不具权威性的工作上下文:尚未决定的探索方向与维护者备注。已交付的行为与既定理由以上文、包代码和相关 Agent Note 为准。 - -- OpenRouter 专属应用归因标头延期到未来显式 OpenRouter 适配器或模式;OpenAI 兼容网关请求只携带共享归因基线。 -- `off` 推理强度绝不会以 `reasoning_effort: 'off'` 过线;它序列化为 `thinking: { type: 'disabled' }` 并省略该字段,从而对拒绝未知强度取值的网关保持协议拼写有效。 - -
+无。 **运行时不变式:** 不发布伴生入口。本包没有独立事件序列或可变数据关系,相关约定在所属 seam 强制执行。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index e3f69ca464..92a8643857 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", - "description": "DeepSeek adapter with Chat Completions and Messages protocols", + "description": "DeepSeek Messages adapter", "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8e46799185..4b0f3704b9 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -1,58 +1,140 @@ -/** Select a DeepSeek wire implementation from one validated configuration generation. */ -import { assertNever } from '@deepseek-ai/dsh-util-values' -import { LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { DeepSeekAdapterOptions } from './common/types.ts' -import { ChatCompletionsAdapter } from './protocols/chat-completions/adapter.ts' -import { DeepSeekFileStore } from './common/file-store.ts' -import { DeepSeekMessagesAdapter } from './protocols/messages/adapter.ts' +/** Direct Messages transport with one cancellable lifecycle per model request. */ -/** One provider route with protocol-local transport and shared credentials and model configuration. */ +import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, ImageAttachmentAccessResolver, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { DeepSeekLlmApiJson } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { catalogModelInfo, modelInfo } from './model-info.ts' +import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from './types.ts' +import { DeepSeekFileStore } from './file-store.ts' +import { MESSAGES_FILES_BETA, messagesApiRoot } from './messages-api.ts' +import { FileResolutionFailure, RequestFiles } from './request-files.ts' +import { prepareRequestExtensions } from './request-extensions.ts' +import { imagePricing, inlineImages, prepareFileIds, prepareImages } from './images.ts' +import { serialize } from './serialize.ts' +import { parseSse } from './sse.ts' +import { translate } from './translate.ts' +import { providerError, providerErrorDetail } from './transport.ts' + +/** DeepSeek provider using Messages content and native thinking replay. */ export class DeepSeekAdapter extends LlmAdapter { private readonly files: DeepSeekFileStore + private readonly imageAccess: ImageAttachmentAccessResolver = (ref) => { + const attachments = this.dependencies.resolveAttachments?.() + return attachments === undefined ? undefined : this.dependencies.resolveImageAccess?.(attachments, ref) + } constructor(private readonly dependencies: DeepSeekAdapterOptions) { super() this.files = dependencies.resolveFiles?.() ?? new DeepSeekFileStore() } - private implementation(): LlmAdapter { + override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } } + override providerRetryPolicy(_provider: string) { return this.dependencies.options().retryPolicy } + override listModels(provider: string) { const connection = this.dependencies.options() - switch (connection.protocol) { - case 'messages': - return new DeepSeekMessagesAdapter({ - connection: () => connection, - apiKey: this.dependencies.resolveApiKey, - userId: this.dependencies.resolveUserId, - attachments: () => this.dependencies.resolveAttachments?.(), - imageAccess: (ref) => { - const attachments = this.dependencies.resolveAttachments?.() - return attachments === undefined ? undefined : this.dependencies.resolveImageAccess?.(attachments, ref) - }, - files: () => this.files, - prepareExtensions: this.dependencies.prepareExtensions, - ...this.dependencies.onReplayDegrade === undefined ? {} : { onReplayDegrade: this.dependencies.onReplayDegrade }, - }) - case 'chat-completions': - return new ChatCompletionsAdapter({ ...this.dependencies, options: () => connection, resolveFiles: () => this.files }) - /* v8 ignore next -- protocol is validated at configuration resolution. */ - default: return assertNever(connection.protocol, 'DeepSeek protocol') + return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model))) + } + override resolveModel(provider: string, model: string, _signal?: AbortSignal) { + return Promise.resolve(modelInfo(this.dependencies.options(), provider, model)) + } + override imageRequestPricing(_provider: string, model: string) { + return imagePricing(this.dependencies.options(), model, this.imageAccess) + } + override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise { + const connection = this.dependencies.options() + return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) }) + } + stream(options: GenerateOptions): AsyncIterable { + return this.generate(options, this.dependencies.options()) + } + + private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator { + const consumer = new AbortController() + const signal = options.signal === undefined ? consumer.signal : AbortSignal.any([consumer.signal, options.signal]) + using watchdog = idleWatchdog(signal, connection.streamIdleTimeoutMs, 'MESSAGES_IDLE') + const iterator = this.request(options, connection, watchdog.signal, () => { watchdog.pulse() }) + try { + while (true) { + const next = await watchdog.next(iterator) + if (next.done) return + yield next.value + } + } catch (error) { + if (timeoutOf(watchdog.signal, 'MESSAGES_IDLE') !== undefined) throw new LlmError('DeepSeek Messages stream idle timeout', 'TIMEOUT', { cause: error }) + if (options.signal?.aborted) throw new LlmError('DeepSeek Messages request aborted', 'ABORTED', { cause: error }) + if (error instanceof LlmError) throw error + throw new LlmError('DeepSeek Messages transport failed', 'TRANSPORT', { cause: error }) + } finally { + consumer.abort() + try { await iterator.return(undefined) } catch (_abortedRequestCleanup) { + // The request already settled; aborting its reader cannot replace that outcome. + } } } - override providerInfo(provider: string) { return this.implementation().providerInfo(provider) } - override providerRetryPolicy(provider: string) { return this.implementation().providerRetryPolicy(provider) } - override listModels(provider: string) { return this.implementation().listModels(provider) } - override resolveModel(provider: string, model: string, signal?: AbortSignal) { - return this.implementation().resolveModel(provider, model, signal) - } - override imageRequestPricing(provider: string, model: string) { - return this.implementation().imageRequestPricing(provider, model) - } - override prepareCall(provider: string, model: string, signal?: AbortSignal): Promise { - return this.implementation().prepareCall(provider, model, signal) - } - stream(options: GenerateOptions): AsyncIterable { - return this.implementation().stream(options) + private async * request( + options: GenerateOptions, connection: Connection, signal: AbortSignal, activity: () => void, + ): AsyncGenerator { + signal.throwIfAborted() + const { messages, versions } = await prepareImages( + options.messages, connection, options.model, this.dependencies.resolveAttachments?.(), this.imageAccess, signal, + ) + const key = await this.dependencies.resolveApiKey(connection) + const files = new RequestFiles(this.files, { baseURL: connection.baseURL, apiKey: key }, + connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) + let inline = false + while (true) { + signal.throwIfAborted() + files.beginAttempt() + let fileIds: Awaited> | undefined + if (!inline) { + try { + fileIds = await prepareFileIds(messages, versions, files) + } catch (error) { + if (!(error instanceof FileResolutionFailure)) throw error + inline = true + continue + } + } + const history = inline ? inlineImages(messages, versions, connection) : messages + const body = serialize(options, connection, history, versions, this.imageAccess, (reason) => { + this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) + }, fileIds) + const extensions = await prepareRequestExtensions(body as Readonly>, { + signal, + ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, + ...options.purpose === undefined ? {} : { purpose: options.purpose }, + }, this.dependencies.prepareExtensions) + signal.throwIfAborted() + const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { + method: 'POST', signal, body: extensions.payload, redirect: 'error', + headers: { + ...attributionHeaders(), + 'content-type': 'application/json', 'accept': 'text/event-stream', + 'x-api-key': key, 'anthropic-version': '2023-06-01', + ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, + 'x-deepseek-harness-user-id': this.dependencies.resolveUserId(), + ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, + ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, + }, + }) + if (!response.ok) { + const text = await response.text() + let raw: unknown + try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { + // HTTP status is authoritative when a gateway does not return JSON. + } + const detail = providerErrorDetail(raw) + if (await files.retry(detail)) continue + const failure = providerError(raw, response.status, response.headers) + const message = files.errorMessage(response.status, failure.message, detail) + throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) + } + await extensions.accept() + if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') + yield* translate(parseSse(response.body, activity), options.model) + return + } } } diff --git a/packages/llm/llm-deepseek/src/config.ts b/packages/llm/llm-deepseek/src/config.ts index 3bf9f5a6d2..edfbc252b4 100644 --- a/packages/llm/llm-deepseek/src/config.ts +++ b/packages/llm/llm-deepseek/src/config.ts @@ -5,10 +5,10 @@ import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { DeepSeekCatalogModel, DeepSeekConnectionOptions, DeepSeekProtocol } from './common/types.ts' -import { DEFAULT_MODELS } from './common/models.ts' -import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILES_API_TIMEOUT_MS } from './common/defaults.ts' -import { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES } from './common/request-pricing.ts' +import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts' +import { DEFAULT_MODELS } from './models.ts' +import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_TOKENS, DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_INLINE_IMAGE_OFFLOAD_BYTE_QUANTUM, DEFAULT_IMAGE_OFFLOAD_COUNT_QUANTUM, DEFAULT_FILE_EXPIRY_SECONDS, DEFAULT_FILE_REFRESH_MARGIN_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, DEFAULT_FILES_API_TIMEOUT_MS } from './defaults.ts' +import { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES } from './request-pricing.ts' const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' @@ -23,8 +23,6 @@ const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModa * reasoning effort resolves to `high`. */ export interface Config { - /** Wire protocol; defaults to messages. Configure through Cordis YAML. */ - protocol?: DeepSeekProtocol /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ @@ -78,7 +76,6 @@ const catalogModel: z = z.object({ }) export const Config: z = z.object({ - protocol: z.union(['chat-completions', 'messages']).default('messages'), apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), baseURL: z.string(), thinking: z.union(['enabled', 'disabled']), @@ -101,10 +98,7 @@ export const Config: z = z.object({ }) /** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */ -export const PUBLIC_BASE_URL = 'https://api.deepseek.com' - -/** Official Messages protocol root. */ -export const MESSAGES_BASE_URL = 'https://api.deepseek.com/anthropic' +export const PUBLIC_BASE_URL = 'https://api.deepseek.com/anthropic' /** Environment variable naming this provider's endpoint, honored only from trusted layers. */ const BASE_URL_ENV = 'DEEPSEEK_BASE_URL' @@ -204,9 +198,8 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee */ export function resolveAdapterOptions(config: Config, environment?: LaunchEnvironmentSnapshot): ResolvedDeepSeekOptions { // Settings updates can reach this resolver without schema validation. - const protocol: string = config.protocol ?? 'messages' - if (protocol !== 'chat-completions' && protocol !== 'messages') { - throw new Error('llm-deepseek: protocol must be chat-completions or messages') + if (Object.hasOwn(config, 'protocol')) { + throw new Error('llm-deepseek: protocol is not configurable; remove it and use a Messages-compatible baseURL') } if (config.thinking === 'disabled' && config.reasoningEffort !== undefined @@ -289,16 +282,12 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro || fileQuotaCleanupBatch > 1_000) { throw new Error('llm-deepseek: fileQuotaCleanupBatch must be an integer from 1 through 1000') } - const baseURL = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value - ?? (protocol === 'messages' ? MESSAGES_BASE_URL : PUBLIC_BASE_URL) - if (protocol === 'messages') { - const parsed = new URL(baseURL) - if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) { - throw new Error('llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment') - } + const baseURL = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value ?? PUBLIC_BASE_URL + const parsed = new URL(baseURL) + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment') } return { - protocol, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL, defaults: { diff --git a/packages/llm/llm-deepseek/src/common/defaults.ts b/packages/llm/llm-deepseek/src/defaults.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/defaults.ts rename to packages/llm/llm-deepseek/src/defaults.ts diff --git a/packages/llm/llm-deepseek/src/common/file-id.ts b/packages/llm/llm-deepseek/src/file-id.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/file-id.ts rename to packages/llm/llm-deepseek/src/file-id.ts diff --git a/packages/llm/llm-deepseek/src/common/file-store.ts b/packages/llm/llm-deepseek/src/file-store.ts similarity index 93% rename from packages/llm/llm-deepseek/src/common/file-store.ts rename to packages/llm/llm-deepseek/src/file-store.ts index 5fcab0cbe1..0963d2eeab 100644 --- a/packages/llm/llm-deepseek/src/common/file-store.ts +++ b/packages/llm/llm-deepseek/src/file-store.ts @@ -7,7 +7,6 @@ import type { DeepSeekFileId } from './file-id.ts' import { messagesApiRoot } from './messages-api.ts' import { deepSeekFileScope, DeepSeekUploadIndex } from './upload-index.ts' import type { DeepSeekUploadRecord } from './upload-index.ts' -import type { DeepSeekProtocol } from './types.ts' /** Shared Files-store limit for each request image, including file-id references. */ export const MAX_IMAGE_BYTES = 32 * 1024 * 1024 @@ -24,8 +23,6 @@ export interface DeepSeekFilePolicy { export interface DeepSeekFileConnection { baseURL: string apiKey: string - /** Files wire protocol selected by the resolved connection. */ - protocol: DeepSeekProtocol } /** Result of one file-id resolution. */ @@ -47,10 +44,10 @@ interface SharedUpload { waiters: number } -/** The Files resource's parent URL distinguishes custom protocol namespaces. */ +/** The Files resource's parent URL identifies the upload namespace. */ function fileScope(connection: DeepSeekFileConnection) { return deepSeekFileScope( - connection.protocol === 'messages' ? messagesApiRoot(connection.baseURL) : connection.baseURL, + messagesApiRoot(connection.baseURL), connection.apiKey, ) } @@ -139,7 +136,6 @@ export class DeepSeekFileStore { return new DeepSeekFilesClient({ baseURL: connection.baseURL, apiKey: connection.apiKey, - protocol: connection.protocol, ...this.fetchImpl === undefined ? {} : { fetch: this.fetchImpl }, }) } @@ -306,23 +302,19 @@ export class DeepSeekFileStore { const client = this.client(connection) let after: DeepSeekFileId | undefined const owned: { id: DeepSeekFileId; createdAt: number }[] = [] - while (connection.protocol === 'messages' || owned.length < count) { + while (true) { const page = await client.list({ ...after === undefined ? {} : { after }, limit: 1_000, - order: 'asc', ...signal === undefined ? {} : { signal }, }) for (const file of page.data) { if (!file.filename.startsWith(OWNED_FILE_PREFIX)) continue owned.push({ id: file.id, createdAt: file.createdAt }) - if (connection.protocol === 'chat-completions' && owned.length === count) break - } - if (connection.protocol === 'messages') { - // Messages offers no ascending-order query; retain the oldest candidates across every page. - owned.sort((left, right) => left.createdAt - right.createdAt) - owned.splice(count) } + // The API offers no ascending-order query; retain the oldest candidates across every page. + owned.sort((left, right) => left.createdAt - right.createdAt) + owned.splice(count) if (!page.hasMore || page.lastId === undefined || page.lastId === after) break after = page.lastId } diff --git a/packages/llm/llm-deepseek/src/common/files-api.ts b/packages/llm/llm-deepseek/src/files-api.ts similarity index 63% rename from packages/llm/llm-deepseek/src/common/files-api.ts rename to packages/llm/llm-deepseek/src/files-api.ts index 8c05165bea..abf9f12191 100644 --- a/packages/llm/llm-deepseek/src/common/files-api.ts +++ b/packages/llm/llm-deepseek/src/files-api.ts @@ -1,11 +1,10 @@ -/** DeepSeek Files API transport for Chat Completions and Messages endpoints. @module dsh-llm-deepseek/files-api */ +/** DeepSeek Files API transport. @module dsh-llm-deepseek/files-api */ import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import { DeepSeekFileId } from './file-id.ts' import type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts' import { messagesApiRoot, MESSAGES_FILES_BETA } from './messages-api.ts' -import type { DeepSeekProtocol } from './types.ts' /** Minimum provider-supported file lifetime. */ export const MIN_FILE_EXPIRY_SECONDS = 3_600 @@ -18,15 +17,13 @@ export const MAX_STORED_FILE_COUNT = 10_000 /** Current per-key storage quota. */ export const MAX_STORED_FILE_BYTES = 25 * 1024 * 1024 * 1024 -/** Validated file metadata normalized from either DeepSeek Files protocol. */ +/** Validated provider file metadata. */ export interface DeepSeekFileObject { id: DeepSeekFileIdType bytes: number createdAt: number filename: string - /** Chat Completions purpose; synthesized as `user_data` for Messages. */ - purpose: 'user_data' - /** Remote Chat Completions expiry or the upload-time Messages reuse deadline; Messages list/retrieve omit this field. */ + /** Upload-time reuse deadline; list and retrieve responses omit this field. */ expiresAt?: number } @@ -74,20 +71,9 @@ export function isFilesQuotaError(error: unknown): error is DeepSeekFilesError { interface FilesApiOptions { baseURL: string apiKey: string - protocol: DeepSeekProtocol fetch?: typeof fetch } -interface WireFileObject { - id?: unknown - object?: unknown - bytes?: unknown - created_at?: unknown - filename?: unknown - purpose?: unknown - expires_at?: unknown -} - function invalidResponse(operation: string): LlmError { return new LlmError(`DeepSeek Files API returned an invalid ${operation} response.`, 'INVALID_RESPONSE') } @@ -107,39 +93,25 @@ async function responseJson(response: Response, operation: string): Promise + const createdAt = typeof wire.created_at === 'string' ? Math.floor(Date.parse(wire.created_at) / 1_000) : NaN if (typeof wire.id !== 'string' || wire.id.length === 0 - || wire.object !== 'file' - || !Number.isSafeInteger(wire.bytes) || (wire.bytes as number) < 0 - || !Number.isSafeInteger(wire.created_at) || (wire.created_at as number) < 0 + || wire.type !== 'file' + || typeof wire.mime_type !== 'string' + || typeof wire.size_bytes !== 'number' || !Number.isSafeInteger(wire.size_bytes) || wire.size_bytes < 0 + || !Number.isSafeInteger(createdAt) || createdAt < 0 || typeof wire.filename !== 'string' || wire.filename.length === 0 - || wire.purpose !== 'user_data' - || (wire.expires_at !== undefined - && (!Number.isSafeInteger(wire.expires_at) || (wire.expires_at as number) < 0))) { + ) { throw invalidResponse(operation) } return { id: DeepSeekFileId(wire.id), - bytes: wire.bytes as number, - createdAt: wire.created_at as number, + bytes: wire.size_bytes, + createdAt, filename: wire.filename, - purpose: 'user_data', - ...wire.expires_at === undefined ? {} : { expiresAt: wire.expires_at as number }, } } -/** Normalize the Messages wire object without interpreting omitted expiration as permanence. */ -function parseMessagesFile(value: unknown, operation: string): DeepSeekFileObject { - if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse(operation) - const wire = value as Record - const createdAt = typeof wire.created_at === 'string' ? Math.floor(Date.parse(wire.created_at) / 1_000) : NaN - if (wire.type !== 'file' || typeof wire.mime_type !== 'string' || !Number.isSafeInteger(createdAt)) throw invalidResponse(operation) - return parseFileObject({ - id: wire.id, object: 'file', bytes: wire.size_bytes, created_at: createdAt, - filename: wire.filename, purpose: 'user_data', - }, operation) -} - function providerErrorDetail(value: unknown): { message?: string; detail: string } { if (value === null || typeof value !== 'object' || Array.isArray(value)) return { detail: '' } const error = (value as { error?: unknown }).error @@ -159,8 +131,6 @@ export class DeepSeekFilesClient { private readonly baseURL: string private readonly apiKey: string private readonly fetchImpl: typeof fetch - private readonly protocol: DeepSeekProtocol - private readonly path: string /** * @param options - endpoint, API-key snapshot, and optional test transport. @@ -168,26 +138,16 @@ export class DeepSeekFilesClient { constructor(options: FilesApiOptions) { this.apiKey = options.apiKey this.fetchImpl = options.fetch ?? globalThis.fetch - this.protocol = options.protocol - this.baseURL = this.protocol === 'messages' - ? messagesApiRoot(options.baseURL) - : options.baseURL.replace(/\/+$/u, '') - this.path = '/files' - } - - private parseFile(value: unknown, operation: string): DeepSeekFileObject { - return this.protocol === 'messages' ? parseMessagesFile(value, operation) : parseFileObject(value, operation) + this.baseURL = messagesApiRoot(options.baseURL) } private async request(path: string, init: RequestInit, signal?: AbortSignal): Promise { let response: Response try { const headers = new Headers(attributionHeaders()) - if (this.protocol === 'messages') { - headers.set('x-api-key', this.apiKey) - headers.set('anthropic-version', '2023-06-01') - headers.set('anthropic-beta', MESSAGES_FILES_BETA) - } else headers.set('authorization', `Bearer ${this.apiKey}`) + headers.set('x-api-key', this.apiKey) + headers.set('anthropic-version', '2023-06-01') + headers.set('anthropic-beta', MESSAGES_FILES_BETA) response = await this.fetchImpl(`${this.baseURL}${path}`, { ...init, redirect: 'error', @@ -235,45 +195,40 @@ export class DeepSeekFilesClient { throw new LlmError('DeepSeek file expiry must be between 3600 and 2592000 seconds.', 'INVALID_REQUEST') } const form = new FormData() - if (this.protocol === 'chat-completions') form.set('purpose', 'user_data') form.set('expires_after[anchor]', 'created_at') form.set('expires_after[seconds]', String(input.expiresAfterSeconds)) form.set('file', new Blob([Uint8Array.from(input.data).buffer], { type: input.mediaType }), input.filename) - const response = await this.request(this.path, { method: 'POST', body: form }, input.signal) - const file = this.parseFile(await responseJson(response, 'upload'), 'upload') - if (this.protocol === 'messages') return { ...file, expiresAt: file.createdAt + input.expiresAfterSeconds } - if (file.expiresAt === undefined) throw invalidResponse('upload') - return { ...file, expiresAt: file.expiresAt } + const response = await this.request('/files', { method: 'POST', body: form }, input.signal) + const file = parseFileObject(await responseJson(response, 'upload'), 'upload') + return { ...file, expiresAt: file.createdAt + input.expiresAfterSeconds } } /** - * List one page of files. Ordering applies only to Chat Completions; Messages owns its page order. - * @param options - pagination, ordering, and cancellation. - * @returns the validated page with null Messages cursors omitted. + * List one provider-ordered page of files. + * @param options - pagination and cancellation. + * @returns the validated page with null cursors omitted. */ async list(options: { after?: DeepSeekFileIdType limit?: number - order?: 'asc' | 'desc' signal?: AbortSignal } = {}): Promise { - const query = new URLSearchParams(this.protocol === 'messages' ? {} : { purpose: 'user_data' }) - if (options.after !== undefined) query.set(this.protocol === 'messages' ? 'after_id' : 'after', options.after) + const query = new URLSearchParams() + if (options.after !== undefined) query.set('after_id', options.after) if (options.limit !== undefined) query.set('limit', String(options.limit)) - if (options.order !== undefined && this.protocol === 'chat-completions') query.set('order', options.order) - const response = await this.request(`${this.path}?${query.toString()}`, { method: 'GET' }, options.signal) + const response = await this.request(`/files?${query.toString()}`, { method: 'GET' }, options.signal) const value: unknown = await responseJson(response, 'list') if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse('list') - const wire = value as { object?: unknown; data?: unknown; first_id?: unknown; last_id?: unknown; has_more?: unknown } - const firstId = this.protocol === 'messages' ? wire.first_id ?? undefined : wire.first_id - const lastId = this.protocol === 'messages' ? wire.last_id ?? undefined : wire.last_id - if ((this.protocol === 'chat-completions' && wire.object !== 'list') || !Array.isArray(wire.data) || typeof wire.has_more !== 'boolean' + const wire = value as { data?: unknown; first_id?: unknown; last_id?: unknown; has_more?: unknown } + const firstId = wire.first_id ?? undefined + const lastId = wire.last_id ?? undefined + if (!Array.isArray(wire.data) || typeof wire.has_more !== 'boolean' || (firstId !== undefined && typeof firstId !== 'string') || (lastId !== undefined && typeof lastId !== 'string')) { throw invalidResponse('list') } return { - data: wire.data.map(item => this.parseFile(item, 'list')), + data: wire.data.map(item => parseFileObject(item, 'list')), ...typeof firstId === 'string' ? { firstId: DeepSeekFileId(firstId) } : {}, ...typeof lastId === 'string' ? { lastId: DeepSeekFileId(lastId) } : {}, hasMore: wire.has_more, @@ -287,8 +242,8 @@ export class DeepSeekFilesClient { * @returns the validated file object. */ async retrieve(fileId: DeepSeekFileIdType, signal?: AbortSignal): Promise { - const response = await this.request(`${this.path}/${encodeURIComponent(fileId)}`, { method: 'GET' }, signal) - return this.parseFile(await responseJson(response, 'retrieve'), 'retrieve') + const response = await this.request(`/files/${encodeURIComponent(fileId)}`, { method: 'GET' }, signal) + return parseFileObject(await responseJson(response, 'retrieve'), 'retrieve') } /** @@ -297,12 +252,10 @@ export class DeepSeekFilesClient { * @param signal - request cancellation. */ async delete(fileId: DeepSeekFileIdType, signal?: AbortSignal): Promise { - const response = await this.request(`${this.path}/${encodeURIComponent(fileId)}`, { method: 'DELETE' }, signal) + const response = await this.request(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' }, signal) const value: unknown = await responseJson(response, 'delete') if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalidResponse('delete') - const wire = value as { id?: unknown; object?: unknown; deleted?: unknown; type?: unknown } - if (wire.id !== fileId || (this.protocol === 'messages' - ? wire.type !== 'file_deleted' - : wire.object !== 'file' || wire.deleted !== true)) throw invalidResponse('delete') + const wire = value as { id?: unknown; type?: unknown } + if (wire.id !== fileId || wire.type !== 'file_deleted') throw invalidResponse('delete') } } diff --git a/packages/llm/llm-deepseek/src/common/image-tokens.ts b/packages/llm/llm-deepseek/src/image-tokens.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/image-tokens.ts rename to packages/llm/llm-deepseek/src/image-tokens.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/images.ts b/packages/llm/llm-deepseek/src/images.ts similarity index 93% rename from packages/llm/llm-deepseek/src/protocols/messages/images.ts rename to packages/llm/llm-deepseek/src/images.ts index f2f32f003e..9d72f4c5dc 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/images.ts +++ b/packages/llm/llm-deepseek/src/images.ts @@ -3,12 +3,12 @@ import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requiredImageOffload } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ImageAttachmentAccessResolver, RequestMessage } from '@deepseek-ai/dsh-llm' -import type { DeepSeekConnectionOptions as Connection } from '../../common/types.ts' -import { resolveRequestImageTarget } from '../../common/request-pricing.ts' -import type { DeepSeekFileId } from '../../common/file-id.ts' -import type { RequestFiles } from '../../common/request-files.ts' +import type { DeepSeekConnectionOptions as Connection } from './types.ts' +import { resolveRequestImageTarget } from './request-pricing.ts' +import type { DeepSeekFileId } from './file-id.ts' +import type { RequestFiles } from './request-files.ts' -export { deepSeekImageRequestPricing as imagePricing } from '../../common/request-pricing.ts' +export { deepSeekImageRequestPricing as imagePricing } from './request-pricing.ts' function bounds(connection: Connection, representation: 'raw' | 'base64') { return { diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index d38246b971..cc5cfdc52f 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,4 +1,4 @@ -/** Register DeepSeek with protocol selection and request-local settings and credentials. */ +/** Register DeepSeek Messages with request-local settings and credentials. */ import type { Context } from '@deepseek-ai/cordis' import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-fs' @@ -10,7 +10,7 @@ import { DeepSeekAdapter } from './adapter.ts' import { Config, resolveAdapterOptions } from './config.ts' import type { ResolvedDeepSeekOptions } from './config.ts' -export { Config, resolveAdapterOptions, PUBLIC_BASE_URL, MESSAGES_BASE_URL } from './config.ts' +export { Config, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' export type { ResolvedDeepSeekOptions } from './config.ts' export { DEFAULT_CONTEXT_WINDOW, @@ -24,10 +24,9 @@ export { DEFAULT_MAX_INLINE_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, -} from './common/defaults.ts' +} from './defaults.ts' export { DeepSeekAdapter } from './adapter.ts' -export type { DeepSeekProtocol } from './common/types.ts' -export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './common/types.ts' +export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './types.ts' export { DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, DEFAULT_MAX_IMAGES_PER_REQUEST, @@ -37,18 +36,17 @@ export { deepSeekImageRequestPricing, resolveRequestImageMaxBytes, resolveRequestImageTarget, -} from './common/request-pricing.ts' -export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './common/image-tokens.ts' -export { DeepSeekFileStore, MAX_IMAGE_BYTES } from './common/file-store.ts' -export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './common/file-store.ts' -export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './common/files-api.ts' -export type { DeepSeekFileObject, DeepSeekFilePage } from './common/files-api.ts' -export { DeepSeekFileId } from './common/file-id.ts' -export type { DeepSeekFileId as DeepSeekFileIdType } from './common/file-id.ts' -export { DeepSeekUploadIndex, deepSeekFileScope } from './common/upload-index.ts' -export type { DeepSeekUploadRecord } from './common/upload-index.ts' -export type { RequestDefaults } from './common/types.ts' -export type * from './protocols/chat-completions/types.ts' +} from './request-pricing.ts' +export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts' +export { DeepSeekFileStore, MAX_IMAGE_BYTES } from './file-store.ts' +export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts' +export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts' +export type { DeepSeekFileObject, DeepSeekFilePage } from './files-api.ts' +export { DeepSeekFileId } from './file-id.ts' +export type { DeepSeekFileId as DeepSeekFileIdType } from './file-id.ts' +export { DeepSeekUploadIndex, deepSeekFileScope } from './upload-index.ts' +export type { DeepSeekUploadRecord } from './upload-index.ts' +export type { RequestDefaults } from './types.ts' export const name = 'llm-deepseek' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/src/common/messages-api.ts b/packages/llm/llm-deepseek/src/messages-api.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/messages-api.ts rename to packages/llm/llm-deepseek/src/messages-api.ts diff --git a/packages/llm/llm-deepseek/src/common/model-info.ts b/packages/llm/llm-deepseek/src/model-info.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/model-info.ts rename to packages/llm/llm-deepseek/src/model-info.ts diff --git a/packages/llm/llm-deepseek/src/common/models.ts b/packages/llm/llm-deepseek/src/models.ts similarity index 92% rename from packages/llm/llm-deepseek/src/common/models.ts rename to packages/llm/llm-deepseek/src/models.ts index 8f614db47b..6bad9113ce 100644 --- a/packages/llm/llm-deepseek/src/common/models.ts +++ b/packages/llm/llm-deepseek/src/models.ts @@ -1,4 +1,4 @@ -/** Default catalog shared by every DeepSeek protocol. */ +/** Default DeepSeek model catalog. */ import { DEFAULT_CONTEXT_WINDOW } from './defaults.ts' import type { DeepSeekCatalogModel } from './types.ts' diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts deleted file mode 100644 index 77d2bf6ca5..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible) - * chat-completions endpoint, emitting harness StreamChunks. The adapter is - * transport-only: connection facts arrive through a thunk resolved once per - * operation and the bearer token through a per-request resolver, so the - * registering plugin owns validation, layering, and credential policy. - * - * @module dsh-llm-deepseek/adapter - */ - -import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' -import type { - ContentBlock, - GenerateOptions, - ImageAttachmentAccess, - LlmModelInfo, - LlmProviderInfo, - PreparedAdapterCall, - LlmResolvedModelInfo, - ResolvedRetryPolicy, - StreamChunk, -} from '@deepseek-ai/dsh-llm' -import type { - AttachmentId, - AttachmentStore, - ImageAttachmentRef, - RequestImageAttachment, -} from '@deepseek-ai/dsh-attachment' -import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import type { - DeepSeekLlmApiJson, -} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import { serializeRequest, serializeRequestWithImages } from './serialize.ts' -import { deepSeekImageRequestPricing, resolveRequestImageTarget } from '../../common/request-pricing.ts' -import { catalogModelInfo, modelInfo } from '../../common/model-info.ts' -import type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from '../../common/types.ts' -import type { DeepSeekFileStore } from '../../common/file-store.ts' -import { FileResolutionFailure, RequestFiles } from '../../common/request-files.ts' -import { prepareRequestExtensions } from '../../common/request-extensions.ts' -import { parseSse } from './sse.ts' -import { translate } from './translate.ts' -import type { WireError, WireRequest } from './types.ts' - -const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' - -function collectImageRefs( - content: readonly ContentBlock[], - refs: Map, -): void { - for (const block of content) { - if (block.type === 'image' && block.offloaded !== true) refs.set(block.attachment.attachmentId, block.attachment) - } -} - -async function prepareRequestImages( - options: GenerateOptions, - attachments: AttachmentStore, - model: DeepSeekCatalogModel, - signal: AbortSignal, -): Promise> { - const refs = new Map() - for (const message of options.messages) collectImageRefs(message.content, refs) - const orderedRefs = [...refs.values()] - const projected = await Promise.all(orderedRefs.map( - ref => attachments.readImageRequest(ref, resolveRequestImageTarget(model, ref), signal), - )) - return new Map(orderedRefs.map((ref, index) => ( - [ref.attachmentId, projected[index] as RequestImageAttachment] - ))) -} - - - -function providerRetryAfterMs(value: string | null): number | undefined { - if (value === null) return undefined - if (/^\d+$/.test(value)) { - const delay = Number(value) * 1_000 - return Number.isFinite(delay) && delay > 0 ? delay : undefined - } - const delay = Date.parse(value) - Date.now() - return Number.isFinite(delay) && delay > 0 ? delay : undefined -} - -function requestId(headers: Headers): ReturnType | undefined { - const value = headers.get('x-request-id') ?? headers.get('x-deepseek-request-id') - return value === null || value.length === 0 ? undefined : ProviderRequestId(value) -} - -/** - * Map an HTTP status to a stable LlmError code. - * @param status - status of a non-2xx provider response. - * @param error - parsed provider error body, when available. - * @returns the normalized harness error code. - */ -export function httpErrorCode(status: number, error?: WireError['error']): string { - if (status === 401 || status === 403) return 'AUTH' - if (status === 413) return 'INVALID_REQUEST' - const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') - if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE - if (status === 429) return 'RATE_LIMIT' - if (status === 400) { - if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE - return 'INVALID_REQUEST' - } - if (status >= 500) return 'SERVER' - return `HTTP_${status}` -} - -/** - * The first real `LlmAdapter`. One instance serves every model name it was - * registered under (the harness model name IS the wire model name). - * - * One stable signal reaches both initial fetch and body reads. Caller aborts - * map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`. - */ -export class ChatCompletionsAdapter extends LlmAdapter { - private readonly files: DeepSeekFileStore - - constructor(private readonly config: DeepSeekAdapterOptions & { resolveFiles: () => DeepSeekFileStore }) { - super() - this.files = config.resolveFiles() - } - - override providerInfo(provider: string): LlmProviderInfo { - return { id: provider, name: 'DeepSeek' } - } - - override providerRetryPolicy(_provider: string): ResolvedRetryPolicy { - return this.config.options().retryPolicy - } - - override imageRequestPricing(_provider: string, model: string): ReturnType { - // The same access resolution the serializer uses, so priced handle and - // placeholder text matches what the request actually sends. - const attachments = this.config.resolveAttachments?.() - const resolveAccess = attachments === undefined - ? undefined - : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => ( - this.config.resolveImageAccess?.(attachments, ref) - ) - return deepSeekImageRequestPricing(this.config.options(), model, resolveAccess) - } - - override listModels(provider: string): Promise { - return Promise.resolve(this.config.options().models.map(model => catalogModelInfo(provider, model))) - } - - override resolveModel( - provider: string, - model: string, - _signal?: AbortSignal, - ): Promise { - return Promise.resolve(modelInfo(this.config.options(), provider, model)) - } - - override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise { - const connection = this.config.options() - return Promise.resolve({ - model: modelInfo(connection, provider, model), - stream: options => this.streamWithConnection(options, connection), - }) - } - - stream(options: GenerateOptions): AsyncIterable { - return this.streamWithConnection(options, this.config.options()) - } - - private async * streamWithConnection( - options: GenerateOptions, - connection: DeepSeekConnectionOptions, - ): AsyncIterable { - // One resolution per stream call: connection facts and the credential - // freeze here and hold for this whole request, so an in-flight stream - // never observes a configuration change and the next call re-resolves. - // The key resolves *from this snapshot*, so an endpoint and the secret - // sent to it can never come from different configuration generations. - const hasImages = options.messages.some(message => contentHasImage(message.content)) - let attachments: AttachmentStore | undefined - if (hasImages) { - const model = connection.models.find(entry => entry.id === options.model) - if (model?.inputModalities?.includes('image') !== true) { - throw new LlmError( - `DeepSeek model "${options.model}" does not accept image input.`, - 'UNSUPPORTED_CONTENT', - ) - } - attachments = this.config.resolveAttachments?.() - if (attachments === undefined) { - throw new LlmError( - 'DeepSeek image conversion requires the durable attachment service.', - 'UNSUPPORTED_CONTENT', - ) - } - } - const apiKey = await this.config.resolveApiKey(connection) - const userId = this.config.resolveUserId() - const consumer = new AbortController() - const upstream = options.signal === undefined - ? consumer.signal - : AbortSignal.any([options.signal, consumer.signal]) - using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) - const iterator = this.request( - options, - watchdog.signal, - connection, - apiKey, - userId, - attachments, - () => { watchdog.pulse() }, - )[Symbol.asyncIterator]() - let exhausted = false - try { - while (true) { - const result = await watchdog.next(iterator) - if (result.done) { - exhausted = true - return - } - yield result.value - } - } catch (error: unknown) { - if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) { - throw new LlmError( - `DeepSeek stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, - 'TIMEOUT', - { cause: error }, - ) - } - if (options.signal?.aborted) { - throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error }) - } - if (error instanceof LlmError) throw error - throw new LlmError(`DeepSeek API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error }) - } finally { - consumer.abort('DeepSeek stream consumer stopped') - if (!exhausted && iterator.return !== undefined) { - try { - await iterator.return() - } catch (_abortedTransportTeardown) { - // The consumer controller already owns termination; a return-time abort cannot add a second outcome. - } - } - } - } - - private async * request( - options: GenerateOptions, - signal: AbortSignal, - connection: DeepSeekConnectionOptions, - apiKey: string, - userId: AnonymousUserId, - attachments: AttachmentStore | undefined, - onActivity: () => void, - ): AsyncIterable { - const headers = { - 'authorization': `Bearer ${apiKey}`, - 'content-type': 'application/json', - 'accept': 'text/event-stream', - ...attributionHeaders(), - 'x-deepseek-harness-user-id': String(userId), - ...options.sessionId !== undefined - ? { 'x-deepseek-harness-session-id': String(options.sessionId) } - : {}, - ...options.purpose === 'compaction' - ? { 'x-deepseek-harness-compact': '1' } - : {}, - } - - const fileConnection = { baseURL: connection.baseURL, apiKey, protocol: connection.protocol } - const model = connection.models.find(entry => entry.id === options.model) - const resolveImageAccess = attachments === undefined - ? undefined - : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref) - const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess } - const requestOptions = options - const requestImages = attachments === undefined || model === undefined - ? new Map() - : await prepareRequestImages(requestOptions, attachments, model, signal) - let representation: 'file' | 'base64' = 'file' - const requestFiles = new RequestFiles( - this.files, fileConnection, connection.filePolicy, connection.filesApiTimeoutMs, signal, onActivity, - ) - while (true) { - requestFiles.beginAttempt() - let body: WireRequest - if (attachments === undefined) { - body = serializeRequest(requestOptions, connection.defaults) - } else if (representation === 'base64') { - body = await serializeRequestWithImages(requestOptions, { - representation: { kind: 'base64' }, - requestImages, - ...imageAccessOptions, - maxRequestImageBytes: connection.maxInlineRequestImageBytes, - maxImagesPerRequest: connection.maxImagesPerRequest, - byteQuantum: connection.inlineImageOffloadByteQuantum, - countQuantum: connection.imageOffloadCountQuantum, - }, connection.defaults) - } else { - try { - body = await serializeRequestWithImages(requestOptions, { - representation: { - kind: 'file', - resolveFileId: (version, _block, location) => requestFiles.resolve(version, location), - }, - requestImages, - ...imageAccessOptions, - maxRequestImageBytes: connection.maxRequestFilesBytes, - maxImagesPerRequest: connection.maxImagesPerRequest, - byteQuantum: connection.imageOffloadByteQuantum, - countQuantum: connection.imageOffloadCountQuantum, - }, connection.defaults) - } catch (error: unknown) { - if (!(error instanceof FileResolutionFailure)) throw error - representation = 'base64' - continue - } - } - const extensions = await prepareRequestExtensions(body as unknown as Readonly>, { - signal, - ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - ...options.purpose === undefined ? {} : { purpose: options.purpose }, - }, this.config.prepareExtensions) - - // TODO(http): adopt the Cordis HTTP service when shared transport configuration - // outweighs its additional runtime dependencies. - let response: Response - try { - response = await fetch(`${connection.baseURL}/chat/completions`, { - method: 'POST', - headers, - body: extensions.payload, - signal, - }) - } catch (error: unknown) { - if (signal.aborted) throw error - throw new LlmError( - `DeepSeek API request to ${connection.baseURL} failed`, - 'TRANSPORT', - { cause: error }, - ) - } - - if (!response.ok) { - let message = `DeepSeek API error (HTTP ${response.status})` - let providerError: WireError['error'] - const rawResponse = await response.text() - try { - const parsed = JSON.parse(rawResponse) as WireError - providerError = parsed.error - if (providerError?.message) message = providerError.message - } catch { - // The HTTP status remains authoritative when a gateway returns malformed JSON. - } - const detail = [providerError?.code, providerError?.type, providerError?.message] - .filter((field): field is string => typeof field === 'string') - .join(' ') - if (await requestFiles.retry(detail)) continue - message = requestFiles.errorMessage(response.status, message, detail) - const delay = providerRetryAfterMs(response.headers.get('retry-after')) - const id = requestId(response.headers) - throw new LlmError(message, httpErrorCode(response.status, providerError), { - cause: new Error(rawResponse.length > 0 ? rawResponse : `DeepSeek HTTP ${response.status}`), - status: response.status, - ...delay === undefined ? {} : { providerRetryAfterMs: delay }, - ...id === undefined ? {} : { requestId: id }, - }) - } - await extensions.accept() - if (!response.body) { - throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') - } - - yield* translate(parseSse(response.body, onActivity)) - return - } - } -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts deleted file mode 100644 index 803e4c6877..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/serialize.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * Serialize harness messages into DeepSeek chat completions. Text-only - * requests retain string user content; the image path resolves durable - * attachments into ordered file-id or inline parts. Tool-role messages map to - * string-only `tool` wire messages; their image parts follow in one shared - * trailing user message. - * @module dsh-llm-deepseek/serialize - */ - -import { contentHasImage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmError, offloadedImageText, projectOffloadedImages, requestImageHandleText, requiredImageOffload } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage } from '@deepseek-ai/dsh-llm' -import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import type { - WireImageContentPart, - WireMessage, - WireRequest, - WireTextContentPart, - WireTool, - WireUserContentPart, -} from './types.ts' - -import type { RequestDefaults } from '../../common/types.ts' - -interface ResolvedThinking { - thinking?: 'enabled' | 'disabled' - reasoningEffort?: 'low' | 'high' | 'max' -} - -/** Provider representation for every retained image in one request. */ -export type ImageRequestRepresentation = - | { - kind: 'file' - /** Resolve a retained request version to a reusable DeepSeek file id. */ - resolveFileId: ( - version: RequestImageAttachment, - block: Extract, - location: ImageWireLocation, - ) => Promise - } - | { kind: 'base64' } - -/** Dependencies required only when the request contains image input. */ -export interface ImageSerializationOptions { - /** One representation used for every retained image in this request. */ - representation: ImageRequestRepresentation - /** Request versions prepared for the conservatively retained normalized attachments, keyed by attachment id. */ - requestImages: ReadonlyMap - /** Resolve current tool access independently from deterministic request-image versions. */ - resolveImageAccess?: ImageAttachmentAccessResolver - /** Positive bound on accumulated represented image bytes. */ - maxRequestImageBytes: number - /** Maximum represented images in one request. */ - maxImagesPerRequest?: number - /** Represented-byte removal step applied after the request exceeds its byte bound. */ - byteQuantum?: number - /** Image-count removal step applied after the request exceeds its count bound. */ - countQuantum?: number -} - -export type { ImageWireLocation } from '../../common/request-files.ts' -import type { ImageWireLocation } from '../../common/request-files.ts' - -const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:' - -/** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */ -function reasoningEffort(effort: NonNullable): 'off' | 'low' | 'high' | 'max' { - if (effort === 'off' || effort === 'low' || effort === 'high' || effort === 'max') { - return effort as 'off' | 'low' | 'high' | 'max' - } - throw new LlmError( - `DeepSeek does not support reasoning effort "${effort}"`, - 'UNSUPPORTED_REASONING_EFFORT', - ) -} - -/** Resolve one legal thinking/effort pair without exposing `off` as a wire effort. */ -function resolveThinking(options: GenerateOptions, defaults: RequestDefaults): ResolvedThinking { - if (options.purpose === 'session-title') return { thinking: 'disabled' } - const effort = options.reasoningEffort === undefined - ? defaults.reasoningEffort - : reasoningEffort(options.reasoningEffort) - if (defaults.thinking === 'disabled' && effort !== undefined && effort !== 'off') { - throw new LlmError( - `DeepSeek deployment does not support reasoning effort "${effort}"`, - 'UNSUPPORTED_REASONING_EFFORT', - ) - } - if (effort === 'off') return { thinking: 'disabled' } - if (effort === 'low' || effort === 'high' || effort === 'max') { - return { thinking: 'enabled', reasoningEffort: effort } - } - return defaults.thinking === undefined ? {} : { thinking: defaults.thinking } -} - -/** Join the text blocks of a message (used for user and tool-role content). */ -function flattenText(blocks: readonly ContentBlock[]): string { - return blocks - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') -} - -/** Reject core image content before any text-flattening path can silently erase it. */ -function assertTextOnly(blocks: readonly ContentBlock[]): void { - if (contentHasImage(blocks)) { - throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') - } -} - -/** Reject developer history before text serialization or image offloading. */ -function assertSupportedRole(message: RequestMessage): void { - // Developer history is persisted for V4; provider serialization is intentionally deferred. - if (message.role === 'developer') throw new LlmError('Developer messages are not supported yet', 'UNSUPPORTED_CONTENT') - if (message.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) { - throw new LlmError('Tool-change blocks require developer role', 'UNSUPPORTED_CONTENT') - } -} - -/** Reject unsupported roles, tool-change blocks, and image roles before history conversion. */ -function assertSupportedHistory(messages: readonly RequestMessage[]): void { - for (const message of messages) { - assertSupportedRole(message) - if (message.role !== 'user' && message.role !== 'tool' && contentHasImage(message.content)) { - throw new LlmError( - `The DeepSeek chat-completions adapter cannot represent image content in a ${message.role} message.`, - 'UNSUPPORTED_CONTENT', - ) - } - } -} - -/** Describe the exact request preview and its model-callable coordinate system. */ -function imageHandle( - ref: ImageAttachmentRef, - version: RequestImageAttachment, - resolveAccess: ImageAttachmentAccessResolver | undefined, - precededByContent: boolean, -): WireTextContentPart { - return { - type: 'text', - text: `${precededByContent ? '\n' : ''}${requestImageHandleText(ref, version, resolveAccess?.(ref))}`, - } -} - -/** Resolve one durable image into its descriptor and transient DeepSeek image part. */ -async function imageParts( - block: Extract, - images: ImageSerializationOptions, - location: ImageWireLocation, - precededByContent: boolean, -): Promise<[WireTextContentPart, WireImageContentPart]> { - const version = images.requestImages.get(block.attachment.attachmentId) - if (version === undefined) { - throw new LlmError( - `DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, - 'INVALID_REQUEST', - ) - } - const image: WireImageContentPart = images.representation.kind === 'file' - ? { type: 'file', file_id: await images.representation.resolveFileId(version, block, location) } - : { - type: 'image_url', - image_url: { url: `data:${version.mediaType};base64,${Buffer.from(version.data).toString('base64')}` }, - } - return [imageHandle(block.attachment, version, images.resolveImageAccess, precededByContent), image] -} - -/** Convert one message's content blocks into ordered wire parts. */ -async function contentParts( - blocks: readonly ContentBlock[], - images: ImageSerializationOptions, - message: number, - nextImage: { value: number }, -): Promise { - const parts: WireUserContentPart[] = [] - for (const block of blocks) { - switch (block.type) { - case 'text': - if (block.text.length > 0) parts.push({ type: 'text', text: block.text }) - break - case 'image': - nextImage.value += 1 - parts.push(...await imageParts(block, images, { message, image: nextImage.value }, parts.length > 0)) - break - default: - // Other merge-extensible blocks are not DeepSeek user-input vocabulary. - break - } - } - return parts -} - -/** Keep text-only user messages on the compact string wire form. */ -function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { - const text: string[] = [] - for (const part of parts) { - if (part.type !== 'text') return [...parts] - text.push(part.text) - } - return text.join('') -} - -/** Serialize one assistant message (text + reasoning + tool calls). */ -function serializeAssistant(message: Message): WireMessage { - const text = flattenText(message.content) - const reasoning = message.content - .filter(block => block.type === 'reasoning') - .map(block => block.text) - .join('') - const toolCalls = message.content - .filter(block => block.type === 'tool-call') - .map(block => ({ - id: block.id, - type: 'function' as const, - function: { name: block.name, arguments: block.arguments }, - })) - - return { - role: 'assistant', - // Text-less turns send "" — NEVER null. Pure tool-call turns: the - // official samples replay message.content verbatim (which is "") and - // some gateways reject null outright. Reasoning-ONLY turns (the model - // can answer entirely in the reasoning channel, e.g. a v4-flash - // greeting): the live API rejects null-content/no-tool_calls assistant - // messages with a 400 ("content or tool_calls must be set"), and since - // the message sits durably in the session log, a null here bricks every - // later turn of that session. - content: text, - // CoT passback on every reasoning-carrying turn. The official rule - // (guides/thinking_mode.mdx) requires it on tool-call turns and ignores it - // elsewhere; a gateway re-encoding the conversation for another vendor - // recovers that turn's upstream thinking signature by hashing this exact - // text, which a tool-call-free turn carries nowhere else. - ...reasoning.length > 0 ? { reasoning_content: reasoning } : {}, - ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}, - } -} - -/** - * Serialize the conversation. Tool-role messages map one-to-one onto - * `{role: 'tool'}` wire messages; user messages contribute their text as a - * plain `{role: 'user'}` wire message. - * @param messages - the harness conversation, in order. - * @returns the wire messages; order preserved. - */ -export function serializeMessages(messages: RequestMessage[]): WireMessage[] { - const wire: WireMessage[] = [] - for (const message of messages) { - assertSupportedRole(message) - assertTextOnly(message.content) - if (message.role === 'system') { - wire.push({ role: 'system', content: flattenText(message.content) }) - continue - } - if (message.role === 'assistant') { - wire.push(serializeAssistant(message)) - continue - } - if (message.role === 'tool') { - wire.push({ - role: 'tool', - tool_call_id: message.toolCallId, - // Empty tool output still needs SOME content on the wire. - content: flattenText(message.content) || '(no output)', - }) - continue - } - // User text always lands on the wire, including empty ones. - wire.push({ role: 'user', content: flattenText(message.content) }) - } - return wire -} - -/** - * Serialize image-capable history after resolving durable attachments. - * Consecutive tool messages keep string `tool` wire messages and share one - * following user message containing their images. - * @param messages - request history whose offloaded occurrences are already placeholder text. - * @param images - prepared request versions, one provider representation, and its budget. - * @returns ordered DeepSeek wire messages. - */ -export async function serializeMessagesWithImages( - messages: readonly RequestMessage[], - images: ImageSerializationOptions, -): Promise { - assertSupportedHistory(messages) - const wire: WireMessage[] = [] - let pendingToolImages: WireImageContentPart[] = [] - const flushToolImages = (): void => { - if (pendingToolImages.length === 0) return - wire.push({ - role: 'user', - content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages], - }) - pendingToolImages = [] - } - - for (const [messageIndex, message] of messages.entries()) { - const nextImage = { value: 0 } - if (message.role === 'system') { - flushToolImages() - wire.push({ role: 'system', content: flattenText(message.content) }) - continue - } - if (message.role === 'assistant') { - flushToolImages() - wire.push(serializeAssistant(message)) - continue - } - if (message.role === 'tool') { - const parts = await contentParts(message.content, images, messageIndex + 1, nextImage) - const imageParts = parts.filter((part): part is WireImageContentPart => part.type !== 'text') - const text = parts.filter(part => part.type === 'text').map(part => part.text).join('') - wire.push({ - role: 'tool', - tool_call_id: message.toolCallId, - content: text || '(no output)', - }) - pendingToolImages.push(...imageParts) - continue - } - // User text serializes as a user message, including empty content. This - // keeps the image-capable route aligned with the text-only route. - const content = userContent(await contentParts(message.content, images, messageIndex + 1, nextImage)) - flushToolImages() - wire.push({ role: 'user', content }) - } - flushToolImages() - return wire -} - -/** Assemble request fields shared by text-only and image-capable conversion. */ -function requestWithMessages( - options: GenerateOptions, - messages: WireMessage[], - defaults: RequestDefaults, -): WireRequest { - // Deferred definitions are persisted for V4; provider loading is intentionally deferred. - if (options.tools?.some(tool => tool.deferLoading === true)) { - throw new LlmError('Deferred tool loading is not supported yet', 'UNSUPPORTED_CONTENT') - } - const tools: WireTool[] | undefined = options.tools?.map(tool => ({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - })) - const resolvedThinking = resolveThinking(options, defaults) - return { - model: options.model, - messages, - stream: true, - stream_options: { include_usage: true }, - ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, - ...resolvedThinking.reasoningEffort !== undefined - ? { reasoning_effort: resolvedThinking.reasoningEffort } - : {}, - ...tools !== undefined && tools.length > 0 ? { tools } : {}, - ...options.temperature !== undefined ? { temperature: options.temperature } : {}, - ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, - ...options.stop !== undefined ? { stop: options.stop } : {}, - } -} - -/** - * Build the full wire request. Always streaming (`stream: true`, usage - * reporting on); optional fields are omitted rather than sent as null, so - * provider defaults apply. - * @param options - the harness request (model, history, system, tools, sampling). - * @param defaults - adapter-level thinking defaults; undefined fields put nothing on the wire. - * @returns the chat-completions request body. - */ -export function serializeRequest( - options: GenerateOptions, - defaults: RequestDefaults = {}, -): WireRequest { - const messages: WireMessage[] = [] - if (options.system !== undefined) { - messages.push({ role: 'system', content: options.system }) - } - messages.push(...serializeMessages(options.messages)) - - return requestWithMessages(options, messages, defaults) -} - -/** - * Reject a request whose retained occurrences, at their exact request-version - * byte lengths under this representation, still exceed the route budget. The - * failure names how many more oldest retained occurrences need durable - * omission before the request can be retried. - */ -function assertRetainedImagesFit(messages: readonly RequestMessage[], images: ImageSerializationOptions): void { - const representation = images.representation.kind === 'file' ? 'raw' : 'base64' - const offloadImages = requiredImageOffload(messages, { - representation, - maxBytes: images.maxRequestImageBytes, - ...images.maxImagesPerRequest === undefined ? {} : { maxImages: images.maxImagesPerRequest }, - ...images.byteQuantum === undefined ? {} : { byteQuantum: images.byteQuantum }, - ...images.countQuantum === undefined ? {} : { countQuantum: images.countQuantum }, - }, (block) => { - const version = images.requestImages.get(block.attachment.attachmentId) - if (version === undefined) { - throw new LlmError(`DeepSeek request image ${block.attachment.attachmentId} was not prepared.`, 'INVALID_REQUEST') - } - return version.bytes - }) - if (offloadImages > 0) { - throw new LlmError( - `DeepSeek ${representation} request images exceed the route budget; ${offloadImages} more oldest occurrence(s) must be offloaded.`, - IMAGE_OFFLOAD_REQUIRED_CODE, - { offloadImages }, - ) - } -} - -/** - * Build one image-capable request while keeping durable bytes out of session - * messages. Offloaded occurrences become per-image text; retained occurrences - * must fit the route budget at their exact request-version byte lengths. - * @param options - harness request containing image-capable user content. - * @param images - request versions, optional current access resolver, and request bounds. - * @param defaults - adapter-level thinking defaults. - * @returns the fully materialized DeepSeek request body. - */ -export async function serializeRequestWithImages( - options: GenerateOptions, - images: ImageSerializationOptions, - defaults: RequestDefaults = {}, -): Promise { - assertSupportedHistory(options.messages) - assertRetainedImagesFit(options.messages, images) - const requestMessages = projectOffloadedImages( - options.messages, - ref => offloadedImageText(ref, images.resolveImageAccess?.(ref)), - ) - const messages: WireMessage[] = [] - if (options.system !== undefined) { - messages.push({ role: 'system', content: options.system }) - } - messages.push(...await serializeMessagesWithImages(requestMessages, images)) - return requestWithMessages(options, messages, defaults) -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts deleted file mode 100644 index 9126a18940..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/sse.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Decode an SSE byte stream into event `data` payloads. Framing — chunk - * reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping, - * multi-`data:` joining — is `eventsource-parser`'s. Comments are reported - * only through an optional transport-activity callback. This module keeps the - * DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns final - * flushing, and EOF before it raises {@link LlmError}. Framing is spec-strict: - * an event dispatches only on its blank-line terminator, so an unterminated - * tail at EOF is truncation, not a flushable payload. - * - * @module dsh-llm-deepseek/sse - */ - -import { EventSourceParserStream } from 'eventsource-parser/stream' -import { LlmError } from '@deepseek-ai/dsh-llm' - -/** The terminal payload DeepSeek (and OpenAI) send after the last chunk. */ -export const DONE = '[DONE]' - -/** - * Parse an SSE byte stream into data payloads. Yields `[DONE]` as the final - * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends - * without it (truncated response — the model call cannot be trusted). - * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence. - * @param onComment - optional transport-activity callback; comments never enter the yielded payload stream. - * @returns each event's data payload in arrival order, the `[DONE]` sentinel last. - */ -export async function* parseSse( - stream: ReadableStream, - onComment?: (comment: string) => void, -): AsyncGenerator { - const events = stream - .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream({ onComment })) - for await (const { data } of events) { - yield data - if (data === DONE) return - } - throw new LlmError('SSE stream ended without [DONE]', 'STREAM_CLOSED') -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts deleted file mode 100644 index 8dd939e547..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/translate.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Translate DeepSeek SSE payloads with one stateful harness block per content, reasoning, or tool - * call index. An empty initial reasoning delta does not open a block. Finish reason and the latest - * usage are deferred until `[DONE]`, covering both finish-attached and trailing usage-only shapes - * while ensuring no chunk follows `finish`. - * - * Translate DeepSeek wire chunks into the harness `StreamChunk` protocol. - * @module dsh-llm-deepseek/translate - */ - -import { brandString } from '@deepseek-ai/dsh-brand' -import { EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, FinishReason, StreamChunk, TokenUsage, ToolCallId } from '@deepseek-ai/dsh-llm' -import { DONE } from './sse.ts' -import type { WireChunk, WireUsage } from './types.ts' - -/** One open block under assembly. */ -interface OpenBlock { - index: number - kind: 'text' | 'reasoning' | 'tool-call' - text: string - /** tool-call only, absent until a delta carries a non-empty value. */ - callId?: string | undefined - name?: string | undefined -} - -/** - * Map the wire finish_reason vocabulary to the harness FinishReason. - * @param reason - the wire `finish_reason` string. - * @returns the mapped reason; unrecognized values (content_filter, …) become `{kind: 'error'}` with the uppercased value as `code`. - */ -export function mapFinishReason(reason: string): FinishReason { - switch (reason) { - case 'stop': return { kind: 'stop' } - case 'tool_calls': return { kind: 'tool-calls' } - case 'length': return { kind: 'max-tokens' } - default: - // content_filter, insufficient_system_resource, future additions. - return { - kind: 'error', - failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() }, - } - } -} - -/** - * Map wire usage fields. DeepSeek's `prompt_tokens` INCLUDES cache hits - * (`prompt_tokens = prompt_cache_hit_tokens + prompt_cache_miss_tokens`, - * api/create-chat-completion); the harness TokenUsage convention is - * DISJOINT counts, so cache reads are subtracted out of `inputTokens`. - * @param usage - wire usage from the finish chunk or the trailing usage-only chunk. - * @returns disjoint harness counts; an exact total is present only when the - * aggregate prompt/completion counters are valid and agree with any wire total. - */ -export function mapUsage(usage: WireUsage): TokenUsage { - const cacheRead = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens - const reasoning = usage.completion_tokens_details?.reasoning_tokens - const combined = usage.prompt_tokens + usage.completion_tokens - const hasExactTotal = Number.isSafeInteger(usage.prompt_tokens) - && usage.prompt_tokens >= 0 - && Number.isSafeInteger(usage.completion_tokens) - && usage.completion_tokens >= 0 - && Number.isSafeInteger(combined) - && (usage.total_tokens === undefined || usage.total_tokens === combined) - return { - inputTokens: usage.prompt_tokens - (cacheRead ?? 0), - outputTokens: usage.completion_tokens, - ...hasExactTotal ? { totalTokens: combined } : {}, - ...cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}, - ...reasoning !== undefined ? { reasoningTokens: reasoning } : {}, - } -} - -/** - * Accept one streamed identity field for a tool call. `id` and `name` are - * identity, not accumulation: the wire sends each once, on the call's first - * delta. A continuation delta that re-sends the field empty — or `null`, which - * some OpenAI-compatible gateways fill in — means "no update", never "clear". - * @param current - the identity established by an earlier delta of this call. - * @param incoming - the field as parsed from this delta. The wire type is a - * claim about a remote encoder, so anything but a non-empty string leaves the - * established value alone rather than overwriting it. - * @returns the identity in force after this delta. - */ -function acceptIdentity(current: string | undefined, incoming: unknown): string | undefined { - return typeof incoming === 'string' && incoming.length > 0 ? incoming : current -} - -/** Assemble the final ContentBlock for one open block. */ -function closeBlock(block: OpenBlock): ContentBlock { - switch (block.kind) { - case 'text': return { type: 'text', text: block.text } - case 'reasoning': return { type: 'reasoning', text: block.text } - case 'tool-call': return { - type: 'tool-call', - id: brandString(block.callId ?? ''), - name: block.name ?? '', - arguments: block.text, - } - } -} - -/** - * Consume SSE data payloads (ending with `[DONE]`) and yield StreamChunks. - * Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`. - * @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated. - * @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel. - * A `stop` (or absent) finish with no opened blocks is a degenerate provider completion and maps to an - * `EMPTY_RESPONSE` error finish instead of a successful empty message. - */ -export async function* translate(payloads: AsyncIterable): AsyncGenerator { - let nextIndex = 0 - let textBlock: OpenBlock | undefined - let reasoningBlock: OpenBlock | undefined - const toolBlocks = new Map() - const order: OpenBlock[] = [] - let pendingFinish: FinishReason | undefined - let pendingUsage: TokenUsage | undefined - - function open(kind: OpenBlock['kind']): OpenBlock { - const block: OpenBlock = { index: nextIndex++, kind, text: '' } - order.push(block) - return block - } - - for await (const payload of payloads) { - if (payload === DONE) { - for (const block of order) { - yield { type: 'block-end', index: block.index, block: closeBlock(block) } - } - if (pendingUsage) yield { type: 'usage', usage: pendingUsage } - const reason = pendingFinish ?? { kind: 'stop' as const } - yield { - type: 'finish', - reason: reason.kind === 'stop' && order.length === 0 - ? { - kind: 'error', - failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, - } - : reason, - } - return - } - - let chunk: WireChunk - try { - chunk = JSON.parse(payload) as WireChunk - } catch { - throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE') - } - - for (const choice of chunk.choices ?? []) { - const delta = choice.delta - - // Reasoning first: thinking mode interleaves it before text. The - // empty-string first chunk must not open a block. - const reasoning = delta?.reasoning_content - if (typeof reasoning === 'string' && reasoning.length > 0) { - if (!reasoningBlock) { - reasoningBlock = open('reasoning') - yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' } - } - reasoningBlock.text += reasoning - yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning } - } - - const content = delta?.content - if (typeof content === 'string' && content.length > 0) { - if (!textBlock) { - textBlock = open('text') - yield { type: 'block-start', index: textBlock.index, blockType: 'text' } - } - textBlock.text += content - yield { type: 'text-delta', index: textBlock.index, text: content } - } - - for (const call of delta?.tool_calls ?? []) { - let block = toolBlocks.get(call.index) - if (!block) { - block = open('tool-call') - toolBlocks.set(call.index, block) - yield { type: 'block-start', index: block.index, blockType: 'tool-call' } - } - block.callId = acceptIdentity(block.callId, call.id) - block.name = acceptIdentity(block.name, call.function?.name) - const fragment = call.function?.arguments ?? '' - block.text += fragment - yield { - type: 'tool-call-delta', - index: block.index, - id: brandString(block.callId ?? ''), - ...block.name !== undefined ? { name: block.name } : {}, - argumentsDelta: fragment, - } - } - - if (typeof choice.finish_reason === 'string') { - pendingFinish = mapFinishReason(choice.finish_reason) - } - } - - // Usage may arrive attached to the finish chunk or as a trailing - // usage-only chunk — keep the latest. - if (chunk.usage) pendingUsage = mapUsage(chunk.usage) - } - - // parseSse guarantees the [DONE] sentinel (or throws); reaching here means - // the payload source violated that contract. - throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED') -} diff --git a/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts b/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts deleted file mode 100644 index 38e73ed39e..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/chat-completions/types.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * DeepSeek chat-completions wire format (OpenAI-compatible). Types only. - * - * Source of truth: the official API docs at - * `~/repos/deepsuite-docs/apps/docs/docs` (api/create-chat-completion, - * guides/thinking_mode.mdx, guides/tool_calls.md), cross-checked against - * live streams from the internal endpoint (2026-06). - * - * @module dsh-llm-deepseek/types - */ - -/** Request body for `POST {baseURL}/chat/completions`. */ -export interface WireRequest { - model: string - messages: WireMessage[] - stream: true - stream_options: { include_usage: true } - /** Thinking-mode toggle (top level, NOT inside extra_body on the wire). */ - thinking?: { type: 'enabled' | 'disabled' } - /** Thinking effort (official levels). */ - reasoning_effort?: 'low' | 'high' | 'max' - tools?: WireTool[] - temperature?: number - max_tokens?: number - /** - * Stop sequences (OpenAI `stop`): generation halts as soon as the model - * produces any one of these strings. Mapped from `GenerateOptions.stop`. - */ - stop?: string[] -} - -/** System-role message: a single string of instructions. */ -export interface WireSystemMessage { - role: 'system' - content: string -} - -/** Text part inside a multimodal user message. */ -export interface WireTextContentPart { - type: 'text' - text: string -} - -/** Files API reference inside a multimodal user message. */ -export interface WireFileContentPart { - type: 'file' - file_id: string -} - -/** Inline base64 data URL inside a multimodal user message. */ -export interface WireImageUrlContentPart { - type: 'image_url' - image_url: { url: string } -} - -/** One image representation accepted by a multimodal user message. */ -export type WireImageContentPart = WireFileContentPart | WireImageUrlContentPart - -/** Ordered input part accepted by a multimodal user message. */ -export type WireUserContentPart = WireTextContentPart | WireImageContentPart - -/** User-role message: text-only string or ordered multimodal input. */ -export interface WireUserMessage { - role: 'user' - content: string | WireUserContentPart[] -} - -/** Tool-role message: the result of one tool call, keyed by its call id. */ -export interface WireToolMessage { - role: 'tool' - tool_call_id: string - content: string -} - -/** One entry of the request `messages` array, discriminated on `role`. */ -export type WireMessage = - | WireSystemMessage - | WireUserMessage - | WireAssistantMessage - | WireToolMessage - -/** - * Assistant-role history message. The harness replays `content: ""` (never - * null) on tool-call-only turns — some gateways reject null — and sends null - * only when the turn carried neither text nor tool calls. - */ -export interface WireAssistantMessage { - role: 'assistant' - content: string | null - /** - * CoT passback, present on every turn whose assistant content carried - * reasoning. REQUIRED on tool-call turns in thinking mode (see - * guides/thinking_mode.mdx § Tool Calls); DeepSeek ignores it elsewhere, - * while a gateway re-encoding for another vendor recovers that turn's - * thinking signature by hashing it. - */ - reasoning_content?: string - tool_calls?: WireToolCall[] -} - -/** A completed tool call replayed on an assistant history message; `arguments` is the raw JSON string. */ -export interface WireToolCall { - id: string - type: 'function' - function: { name: string; arguments: string } -} - -/** One entry of the request `tools` array; `parameters` is a JSON Schema object. */ -export interface WireTool { - type: 'function' - function: { - name: string - description: string - parameters: Record - } -} - -/** One parsed SSE `data:` payload (a chat.completion.chunk). */ -export interface WireChunk { - choices?: WireChoice[] - /** Arrives attached to the finish chunk and/or as a trailing usage-only chunk. */ - usage?: WireUsage | null -} - -/** One streamed choice (requests always ask for a single one); `finish_reason` is non-null only on its terminal chunk. */ -export interface WireChoice { - delta?: WireDelta - finish_reason?: string | null -} - -/** The incremental content of one streamed choice; any subset of fields may be present per chunk. */ -export interface WireDelta { - role?: string - /** Visible text. Null/empty on reasoning/tool-call chunks. */ - content?: string | null - /** - * Thinking-mode CoT. The FIRST chunk carries an empty string (must not - * open a reasoning block); absent entirely in non-thinking mode. - */ - reasoning_content?: string | null - tool_calls?: WireToolCallDelta[] -} - -/** A streamed fragment of one tool call; fragments sharing an `index` concatenate into one call. */ -export interface WireToolCallDelta { - /** Disambiguates parallel tool calls; stable across a call's deltas. */ - index: number - /** - * Carried by the first delta of each call. Gateways observed in the wild - * repeat it on continuation deltas as `''` or `null`; both mean "unchanged". - */ - id?: string | null - type?: 'function' - function?: { - /** Carried by the first delta of each call, with the same `''`/`null` repetition as {@link WireToolCallDelta.id}. */ - name?: string | null - /** Argument JSON fragment (concatenate across deltas). */ - arguments?: string | null - } -} - -/** - * Wire token accounting. `prompt_tokens` INCLUDES cache hits (it equals - * `prompt_cache_hit_tokens + prompt_cache_miss_tokens`); `mapUsage` subtracts - * them to keep the harness convention of disjoint counts. - * `prompt_tokens_details.cached_tokens` is the OpenAI-compat spelling of the - * hit count. - */ -export interface WireUsage { - prompt_tokens: number - completion_tokens: number - /** Provider-reported aggregate across prompt and completion tokens. */ - total_tokens?: number - prompt_cache_hit_tokens?: number - prompt_cache_miss_tokens?: number - prompt_tokens_details?: { cached_tokens?: number } - completion_tokens_details?: { reasoning_tokens?: number } -} - -/** Non-2xx error body. */ -export interface WireError { - error?: { message?: string; type?: string; code?: string } -} diff --git a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts b/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts deleted file mode 100644 index e5304b8380..0000000000 --- a/packages/llm/llm-deepseek/src/protocols/messages/adapter.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** Direct Messages transport with one cancellable lifecycle per model request. */ - -import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, ImageAttachmentAccessResolver, PreparedAdapterCall, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { DeepSeekLlmApiJson } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { catalogModelInfo, modelInfo } from '../../common/model-info.ts' -import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions as Connection } from '../../common/types.ts' -import type { DeepSeekFileStore } from '../../common/file-store.ts' -import { MESSAGES_FILES_BETA, messagesApiRoot } from '../../common/messages-api.ts' -import { FileResolutionFailure, RequestFiles } from '../../common/request-files.ts' -import { prepareRequestExtensions } from '../../common/request-extensions.ts' -import { imagePricing, inlineImages, prepareFileIds, prepareImages } from './images.ts' -import { serialize } from './serialize.ts' -import { parseSse } from './sse.ts' -import { translate } from './translate.ts' -import { providerError, providerErrorDetail } from './transport.ts' - -/** Request-local dependencies supplied by the owning Cordis plugin. */ -export interface AdapterDependencies { - /** Resolve a single validated configuration generation. */ - connection(): Connection - /** Resolve the key named by that same generation. */ - apiKey(connection: Connection): Promise - /** Stable anonymous Harness identity. */ - userId(): string - /** Current attachment service; absence is valid for text requests. */ - attachments(): AttachmentStore | undefined - /** Current execution-world attachment path. */ - imageAccess: ImageAttachmentAccessResolver - /** Process-wide Files upload reuse and recovery. */ - files(): DeepSeekFileStore - /** Prepare plugin-contributed fields for this exact HTTP request. */ - prepareExtensions: DeepSeekAdapterOptions['prepareExtensions'] - /** Report discarded replay metadata without exposing durable content or signatures. */ - onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void -} - -/** DeepSeek provider using Messages content and native thinking replay. */ -export class DeepSeekMessagesAdapter extends LlmAdapter { - constructor(private readonly dependencies: AdapterDependencies) { super() } - - override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } } - override providerRetryPolicy(_provider: string) { return this.dependencies.connection().retryPolicy } - override listModels(provider: string) { - const connection = this.dependencies.connection() - return Promise.resolve(connection.models.map(model => catalogModelInfo(provider, model))) - } - override resolveModel(provider: string, model: string) { - return Promise.resolve(modelInfo(this.dependencies.connection(), provider, model)) - } - override imageRequestPricing(_provider: string, model: string) { - return imagePricing(this.dependencies.connection(), model, this.dependencies.imageAccess) - } - override prepareCall(provider: string, model: string): Promise { - const connection = this.dependencies.connection() - return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) }) - } - stream(options: GenerateOptions): AsyncIterable { - return this.generate(options, this.dependencies.connection()) - } - - private async * generate(options: GenerateOptions, connection: Connection): AsyncGenerator { - const consumer = new AbortController() - const signal = options.signal === undefined ? consumer.signal : AbortSignal.any([consumer.signal, options.signal]) - using watchdog = idleWatchdog(signal, connection.streamIdleTimeoutMs, 'MESSAGES_IDLE') - const iterator = this.request(options, connection, watchdog.signal, () => { watchdog.pulse() }) - try { - while (true) { - const next = await watchdog.next(iterator) - if (next.done) return - yield next.value - } - } catch (error) { - if (timeoutOf(watchdog.signal, 'MESSAGES_IDLE') !== undefined) throw new LlmError('DeepSeek Messages stream idle timeout', 'TIMEOUT', { cause: error }) - if (options.signal?.aborted) throw new LlmError('DeepSeek Messages request aborted', 'ABORTED', { cause: error }) - if (error instanceof LlmError) throw error - throw new LlmError('DeepSeek Messages transport failed', 'TRANSPORT', { cause: error }) - } finally { - consumer.abort() - try { await iterator.return(undefined) } catch (_abortedRequestCleanup) { - // The request already settled; aborting its reader cannot replace that outcome. - } - } - } - - private async * request( - options: GenerateOptions, connection: Connection, signal: AbortSignal, activity: () => void, - ): AsyncGenerator { - signal.throwIfAborted() - const { messages, versions } = await prepareImages( - options.messages, connection, options.model, this.dependencies.attachments(), this.dependencies.imageAccess, signal, - ) - const key = await this.dependencies.apiKey(connection) - const files = new RequestFiles(this.dependencies.files(), { baseURL: connection.baseURL, apiKey: key, protocol: 'messages' }, - connection.filePolicy, connection.filesApiTimeoutMs, signal, activity) - let inline = false - while (true) { - signal.throwIfAborted() - files.beginAttempt() - let fileIds: Awaited> | undefined - if (!inline) { - try { - fileIds = await prepareFileIds(messages, versions, files) - } catch (error) { - if (!(error instanceof FileResolutionFailure)) throw error - inline = true - continue - } - } - const history = inline ? inlineImages(messages, versions, connection) : messages - const body = serialize(options, connection, history, versions, this.dependencies.imageAccess, (reason) => { - this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) - }, fileIds) - const extensions = await prepareRequestExtensions(body as unknown as Readonly>, { - signal, - ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, - ...options.purpose === undefined ? {} : { purpose: options.purpose }, - }, this.dependencies.prepareExtensions) - signal.throwIfAborted() - const response = await fetch(`${messagesApiRoot(connection.baseURL)}/messages`, { - method: 'POST', signal, body: extensions.payload, redirect: 'error', - headers: { - ...attributionHeaders(), - 'content-type': 'application/json', 'accept': 'text/event-stream', - 'x-api-key': key, 'anthropic-version': '2023-06-01', - ...fileIds === undefined || fileIds.size === 0 ? {} : { 'anthropic-beta': MESSAGES_FILES_BETA }, - 'x-deepseek-harness-user-id': this.dependencies.userId(), - ...options.sessionId === undefined ? {} : { 'x-deepseek-harness-session-id': String(options.sessionId) }, - ...options.purpose === 'compaction' ? { 'x-deepseek-harness-compact': '1' } : {}, - }, - }) - if (!response.ok) { - const text = await response.text() - let raw: unknown - try { raw = JSON.parse(text) } catch (_nonJsonGatewayError) { - // HTTP status is authoritative when a gateway does not return JSON. - } - const detail = providerErrorDetail(raw) - if (await files.retry(detail)) continue - const failure = providerError(raw, response.status, response.headers) - const message = files.errorMessage(response.status, failure.message, detail) - throw new LlmError(message, failure.code, { ...failure.failure, cause: new Error(text) }) - } - await extensions.accept() - if (response.body === null) throw new LlmError('DeepSeek Messages returned no response body', 'EMPTY_RESPONSE') - yield* translate(parseSse(response.body, activity), options.model) - return - } - } -} diff --git a/packages/llm/llm-deepseek/src/protocols/messages/replay.ts b/packages/llm/llm-deepseek/src/replay.ts similarity index 93% rename from packages/llm/llm-deepseek/src/protocols/messages/replay.ts rename to packages/llm/llm-deepseek/src/replay.ts index d6ff95804b..f285ebaeae 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/replay.ts +++ b/packages/llm/llm-deepseek/src/replay.ts @@ -56,9 +56,9 @@ function validateReplay(message: Message, model: string): ReplayBlock[] | undefi const blocks = envelope.blocks.map((value, index): ReplayBlock => { const block = object(value, 'INVALID_REPLAY_STATE') if (block.type !== message.content[index]?.type - || !['text', 'reasoning', 'tool-call'].includes(String(block.type))) return fail('block type mismatch') + || (block.type !== 'text' && block.type !== 'reasoning' && block.type !== 'tool-call')) return fail('block type mismatch') if (block.signature !== undefined && (block.type !== 'reasoning' || typeof block.signature !== 'string')) return fail('invalid signature') - return block as unknown as ReplayBlock + return { type: block.type, ...typeof block.signature === 'string' ? { signature: block.signature } : {} } }) return response.model === model ? blocks : undefined } diff --git a/packages/llm/llm-deepseek/src/common/request-extensions.ts b/packages/llm/llm-deepseek/src/request-extensions.ts similarity index 87% rename from packages/llm/llm-deepseek/src/common/request-extensions.ts rename to packages/llm/llm-deepseek/src/request-extensions.ts index 906e778382..e1cb10febb 100644 --- a/packages/llm/llm-deepseek/src/common/request-extensions.ts +++ b/packages/llm/llm-deepseek/src/request-extensions.ts @@ -5,9 +5,9 @@ import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions } import type { DeepSeekAdapterOptions } from './types.ts' /** - * Merge contributions without replacing protocol-owned fields. Preparation and - * acceptance failures retain the same error category across DeepSeek protocols. - * @param body - serialized protocol request before extension fields. + * Merge contributions without replacing Messages fields. Preparation and + * acceptance failures report REQUEST_EXTENSION. + * @param body - serialized Messages request before extension fields. * @param options - request identity, purpose, and cancellation. * @param prepare - contributor registry captured for this adapter. * @returns HTTP payload and a commit to invoke only after a successful HTTP response. diff --git a/packages/llm/llm-deepseek/src/common/request-files.ts b/packages/llm/llm-deepseek/src/request-files.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/request-files.ts rename to packages/llm/llm-deepseek/src/request-files.ts diff --git a/packages/llm/llm-deepseek/src/common/request-pricing.ts b/packages/llm/llm-deepseek/src/request-pricing.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/request-pricing.ts rename to packages/llm/llm-deepseek/src/request-pricing.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts similarity index 98% rename from packages/llm/llm-deepseek/src/protocols/messages/serialize.ts rename to packages/llm/llm-deepseek/src/serialize.ts index 78448e6e51..a78767df7d 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -3,10 +3,10 @@ import { LlmError, requestImageHandleText } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, ImageAttachmentAccessResolver, Message, RequestMessage } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import type { DeepSeekConnectionOptions as Connection } from '../../common/types.ts' -import type { DeepSeekFileId } from '../../common/file-id.ts' +import type { DeepSeekConnectionOptions as Connection } from './types.ts' +import type { DeepSeekFileId } from './file-id.ts' import { readReplay } from './replay.ts' -import type { WireBlock, WireInput, WireMessage, WireRequest } from './types.ts' +import type { WireBlock, WireInput, WireMessage, WireRequest } from './wire-types.ts' function unsupported(type: string): never { throw new LlmError(`DeepSeek Messages cannot represent ${type}`, 'UNSUPPORTED_CONTENT') diff --git a/packages/llm/llm-deepseek/src/protocols/messages/sse.ts b/packages/llm/llm-deepseek/src/sse.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/sse.ts rename to packages/llm/llm-deepseek/src/sse.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/translate.ts b/packages/llm/llm-deepseek/src/translate.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/translate.ts rename to packages/llm/llm-deepseek/src/translate.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/transport.ts b/packages/llm/llm-deepseek/src/transport.ts similarity index 100% rename from packages/llm/llm-deepseek/src/protocols/messages/transport.ts rename to packages/llm/llm-deepseek/src/transport.ts diff --git a/packages/llm/llm-deepseek/src/common/types.ts b/packages/llm/llm-deepseek/src/types.ts similarity index 92% rename from packages/llm/llm-deepseek/src/common/types.ts rename to packages/llm/llm-deepseek/src/types.ts index d1c4932317..180227f484 100644 --- a/packages/llm/llm-deepseek/src/common/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -1,4 +1,4 @@ -/** Shared catalog and request-local dependencies for DeepSeek protocols. */ +/** Model catalog and request-local dependencies for DeepSeek Messages. */ import type { ModelModality, SystemPromptUpdate, ResolvedRetryPolicy, ImageAttachmentAccess } from '@deepseek-ai/dsh-llm' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' @@ -6,9 +6,6 @@ import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import type { DeepSeekLlmApiExtensionRequest, PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { DeepSeekFileStore, DeepSeekFilePolicy } from './file-store.ts' -/** Supported wire implementations; Responses is not yet implemented. */ -export type DeepSeekProtocol = 'chat-completions' | 'messages' - /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ @@ -46,9 +43,7 @@ export interface DeepSeekCatalogModel { * makes a configuration change reach the next request without re-registration. */ export interface DeepSeekConnectionOptions { - /** Wire protocol selected by plugin configuration. */ - protocol: DeepSeekProtocol - /** Root compatible with the selected protocol; custom paths remain unchanged. */ + /** Messages API root; custom paths remain unchanged. */ baseURL: string /** * Credential reference of this same resolution, resolved per request. @@ -94,7 +89,7 @@ export interface DeepSeekAdapterOptions { /** Current validated connection facts; called once per operation. */ options: () => DeepSeekConnectionOptions /** - * Resolve the bearer token for the connection facts of one request. The + * Resolve the API key for the connection facts of one request. The * snapshot is passed in — never re-read — so the key can only ever come * from the same resolution as the endpoint it is sent to. Throws `LlmError` * `MISSING_CREDENTIAL` when no key is available anywhere. diff --git a/packages/llm/llm-deepseek/src/common/upload-index.ts b/packages/llm/llm-deepseek/src/upload-index.ts similarity index 100% rename from packages/llm/llm-deepseek/src/common/upload-index.ts rename to packages/llm/llm-deepseek/src/upload-index.ts diff --git a/packages/llm/llm-deepseek/src/protocols/messages/types.ts b/packages/llm/llm-deepseek/src/wire-types.ts similarity index 95% rename from packages/llm/llm-deepseek/src/protocols/messages/types.ts rename to packages/llm/llm-deepseek/src/wire-types.ts index 582ffc7545..c72f0c9d47 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/types.ts +++ b/packages/llm/llm-deepseek/src/wire-types.ts @@ -13,13 +13,13 @@ export type WireBlock = WireInput | { type: 'tool_result'; tool_use_id: string; content: WireInput[]; is_error?: boolean } /** Conversation turn; capable routes retain later system updates in message history. */ -export interface WireMessage { +export type WireMessage = { role: 'user' | 'assistant' | 'system' content: WireBlock[] } /** JSON body submitted to the resolved Messages endpoint. */ -export interface WireRequest { +export type WireRequest = { model: string stream: true max_tokens: number diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 2a68e915cc..bffec7e6ce 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,466 +1,282 @@ -import { readFileSync } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +/** + * Real Messages round trips use the official root and require credentials. + * System-update checks additionally require DEEPSEEK_IN_HISTORY_MODEL. + */ +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { randomBytes } from 'node:crypto' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { createRequire } from 'node:module' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, LoggerLevel } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' -import LlmRuntime, { createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage, createSystemMessage } from '@deepseek-ai/dsh-llm' -import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' -import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import LocalAttachments from '@deepseek-ai/dsh-attachment-local' -import type { - ImageAttachmentLimits, - ImageAttachmentRef, - ImageRequestTarget, - RequestImageAttachment, - SaveImageAttachment, - StoredImageAttachment, -} from '@deepseek-ai/dsh-attachment' -import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import type { Config } from '@deepseek-ai/dsh-llm-deepseek' -import type { WireMessage, WireRequest } from '../src/protocols/chat-completions/types.ts' -import { assemble, type AssembledResult } from './assemble.ts' +import * as Messages from '../src/index.ts' +import { DeepSeekFilesClient } from '../src/files-api.ts' +import { MESSAGES_FILES_BETA } from '../src/messages-api.ts' +import { assemble, options, user, sourceModuleLoader } from './helpers.ts' -/** - * Real-API e2e for the direct-fetch adapter: V4 Flash across thinking modes - * and a max-effort tool round trip with reasoning passback. The suite skips - * entirely without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally - * requires $DEEPSEEK_VISION_E2E=1, and the Flash image/system-update smoke - * requires $DEEPSEEK_FLASH_E2E=1 (see vitest.e2e.config.ts). - */ - -const FLASH = 'deepseek-v4-flash' -const VISION = 'deepseek-v4-flash-vision-exp' -const VISION_E2E_ENABLED = process.env.DEEPSEEK_VISION_E2E === '1' -/** A model whose endpoint reads the latest `system` message at any position; unset skips the in-history smoke. */ const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL -const TEST_PNG = Uint8Array.from(readFileSync( - new URL('../../llm-pi-ai/tests/fixtures/qr-code.png', import.meta.url), -)) -const contexts: Context[] = [] -let identityHome: string - -class E2eAttachmentStore extends AttachmentStore { - readonly imageLimits: ImageAttachmentLimits = { - maxImageBytes: TEST_PNG.byteLength, - maxImagesPerMessage: 1, - maxMessageImageBytes: TEST_PNG.byteLength, - maxImagePixels: 256 * 256, - maxImageDimension: 256, - mediaTypes: ['image/png'], - } - readonly ref: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${randomBytes(32).toString('hex')}`), - mediaType: 'image/png', - bytes: TEST_PNG.byteLength, - width: 256, - height: 256, - name: 'files-api-e2e.png', - } - readonly version: RequestImageAttachment = { - variantId: ImageVariantId(`sha256:${randomBytes(32).toString('hex')}`), - attachment: this.ref, - data: TEST_PNG, - mediaType: 'image/png', - bytes: TEST_PNG.byteLength, - width: 256, - height: 256, - depth: 'uchar', - space: 'srgb', - hasAlpha: false, - } - - validateImage(_input: SaveImageAttachment): Promise { - return Promise.resolve() - } - - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve(this.ref) - } - - readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { - return Promise.resolve({ ref, data: TEST_PNG }) - } - - override readImageRequest( - _ref: ImageAttachmentRef, - _target: ImageRequestTarget, - _signal?: AbortSignal, - ): Promise { - return Promise.resolve(this.version) - } -} - -beforeEach(async () => { - identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-')) - vi.stubEnv('DSH_HOME', identityHome) +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!() + vi.unstubAllEnvs() + vi.unstubAllGlobals() }) - -async function harness(model: string, config: Partial = {}) { +async function boot(models?: Messages.Config['models']) { + const home = await mkdtemp(join(tmpdir(), 'dsh-messages-e2e-')) + cleanups.push(() => rm(home, { recursive: true, force: true })) + vi.stubEnv('DSH_HOME', home) const ctx = new Context() - contexts.push(ctx) + cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(LlmRuntime) - await ctx.plugin(E2eAttachmentStore) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: LlmDeepSeek.PUBLIC_BASE_URL, - ...model === VISION ? { models: [{ id: VISION, inputModalities: ['text', 'image'] }] } : {}, - ...config, + await ctx.plugin(Messages, { + baseURL: Messages.PUBLIC_BASE_URL, + maxTokens: 4096, + ...models === undefined ? {} : { models }, }) return ctx } +const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } } -afterEach(async () => { - await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) - vi.unstubAllGlobals() - vi.unstubAllEnvs() - await rm(identityHome, { recursive: true, force: true }) -}) - -function ask(text: string): Message[] { - return [createUserMessage({ - content: [{ type: 'text', text }], - source: { kind: 'user' }, - })] -} - -function textOf(result: AssembledResult): string { - return result.message.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') -} - -const weatherTool: ToolSchema = { - name: 'get_weather', - description: 'Get the current weather for a city.', - parameters: { - type: 'object', - properties: { city: { type: 'string', description: 'City name' } }, - required: ['city'], - }, -} - -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { - it.skipIf(process.env.DEEPSEEK_FLASH_E2E !== '1')('deepseek-flash accepts images and retains system updates', async () => { - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(LlmRuntime) - await ctx.plugin(LocalAttachments) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL, maxTokens: 4096 }) - const model = 'deepseek-flash' - await expect(ctx.llm.resolveModelInfo('deepseek-official', model)).resolves.toMatchObject({ - inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', - }) - const attachment = await ctx.attachments.saveImage({ data: readFileSync(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) - const message = ask('What is the dominant color of this image?')[0]! - const history: Message[] = [ - createSystemMessage('Answer with one English color word.'), - { ...message, content: [...message.content, { type: 'image', attachment }] }, - ] - const reply = async () => { - const response = await assemble(ctx, { model, messages: history, reasoningEffort: ReasoningEffortId('high') }) - expect(response.finish.kind, JSON.stringify(response.finish)).toBe('stop') +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => { + it.skipIf(!IN_HISTORY_MODEL).each([false, true])('updates system instructions during a conversation, in-history=%s', async (inHistory) => { + const model = IN_HISTORY_MODEL as string + // Each case owns the capability, even for a model with an in-history catalog default. + const ctx = await boot([{ id: model, ...inHistory ? { systemPromptUpdate: 'in-history' as const } : {} }]) + const history: Message[] = [createSystemMessage('Reply to every user message with exactly PROMPT_FIRST.'), user('Answer now.')] + const reply = async (expected: string) => { + const saved = JSON.stringify(history) + const response = await assemble(ctx.llm.stream(options({ model, messages: history, reasoningEffort: ReasoningEffortId('high') })), model) + expect(response.assembler.finish.kind).toBe('stop') + expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain(expected) + expect(JSON.stringify(history)).toBe(saved) history.push(response.message) - return textOf(response).trim().toLowerCase() } - expect(await reply()).toMatch(/^red[.!]?$/) - history.push(createSystemMessage('Reply to every user message with exactly banana.'), ...ask('Answer now.')) - expect(await reply()).toBe('banana') - history.push(...ask('Answer again.')) - expect(await reply()).toBe('banana') + await reply('PROMPT_FIRST') + history.push(createSystemMessage('Reply to every user message with exactly PROMPT_SECOND.'), user('Answer again.')) + await reply('PROMPT_SECOND') + const withoutSystem = history.filter(message => message.role !== 'system') + history.splice(0, history.length, createSystemMessage(''), ...withoutSystem, user('Reply with exactly PROMPT_CLEARED.')) + await reply('PROMPT_CLEARED') }) - it.skipIf(!VISION_E2E_ENABLED)('uses the built-in official route to upload, reference, and delete one image', async () => { - const key = process.env.DEEPSEEK_API_KEY - if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') - const baseURL = LlmDeepSeek.PUBLIC_BASE_URL - const ctx = await harness(VISION, { baseURL }) - await ctx.plugin(E2eAttachmentStore) - const attachments = ctx.attachments as E2eAttachmentStore - let uploadedFile: LlmDeepSeek.DeepSeekFileIdType | undefined - const nativeFetch = globalThis.fetch - const observedFetch: typeof fetch = async (input, init) => { - const response = await nativeFetch(input, init) - const url = new URL(input instanceof Request ? input.url : input) - const method = init?.method ?? (input instanceof Request ? input.method : 'GET') - if (method === 'POST' && url.pathname.endsWith('/files') && response.ok) { - const value = await response.clone().json() as { id?: unknown } - if (typeof value.id === 'string') uploadedFile = LlmDeepSeek.DeepSeekFileId(value.id) + it('uploads, lists, retrieves, reuses, and replaces a deleted Files image across Messages requests', async () => { + const ctx = await boot() + await ctx.plugin(LocalAttachments) + const fetchImpl = globalThis.fetch + const uploads: string[] = [] + const bodies: string[] = [] + const files = new DeepSeekFilesClient({ + baseURL: Messages.PUBLIC_BASE_URL, apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl, + }) + const ownedFiles = new Set>() + cleanups.push(async () => { + for (const id of ownedFiles) await files.delete(id) + }) + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + const response = await fetchImpl(input, init) + if (url === `${Messages.PUBLIC_BASE_URL}/v1/files` && init?.method === 'POST' && response.ok) { + const file = await response.clone().json() as { id: string } + uploads.push(file.id) + ownedFiles.add(Messages.DeepSeekFileId(file.id)) + } + if (url === `${Messages.PUBLIC_BASE_URL}/v1/messages`) { + expect(new Headers(init?.headers).get('anthropic-beta')).toBe(MESSAGES_FILES_BETA) + if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') + bodies.push(init.body) } return response + }) + const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) + const message = user('What is the dominant color of this image? Reply with one English color word.') + const request = options({ + model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('off'), + messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }], + }) + for (let run = 0; run < 2; run++) { + const result = await assemble(ctx.llm.stream(request), request.model) + expect(result.assembler.finish.kind).toBe('stop') + expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red') } - vi.stubGlobal('fetch', observedFetch) - const files = new LlmDeepSeek.DeepSeekFilesClient({ protocol: 'chat-completions', baseURL, apiKey: key }) - - try { - const result = await assemble(ctx, { - model: VISION, - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'Briefly describe this image.' }, - { type: 'image', attachment: attachments.ref }, - ], - source: { kind: 'user' }, - })], - maxTokens: 100, - }) - expect( - result.finish.kind, - `DeepSeek vision result: ${JSON.stringify(result.finish)}`, - ).toBe('stop') - expect(textOf(result).trim().length).toBeGreaterThan(0) - expect(uploadedFile).toMatch(/^file-api-/u) - } finally { - if (uploadedFile !== undefined) await files.delete(uploadedFile) + expect(uploads).toHaveLength(1) + expect(bodies).toHaveLength(2) + expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true) + const fileId = Messages.DeepSeekFileId(uploads[0]!) + const retrieved = await files.retrieve(fileId) + expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes }) + expect(retrieved.expiresAt).toBeUndefined() + let page = await files.list({ limit: 1_000 }) + const cursors = new Set() + while (!page.data.some(file => file.id === fileId) && page.hasMore) { + expect(page.lastId).toBeDefined() + const after = page.lastId! + expect(cursors.has(after)).toBe(false) + cursors.add(after) + page = await files.list({ after, limit: 1_000 }) } + expect(page.data).toContainEqual(retrieved) + await files.delete(fileId) + ownedFiles.delete(fileId) + const recovered = await assemble(ctx.llm.stream(request), request.model) + expect(recovered.assembler.finish.kind).toBe('stop') + expect(recovered.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red') + expect(uploads).toHaveLength(2) + expect(uploads[1]).not.toBe(fileId) + expect(bodies).toHaveLength(4) + expect(bodies[2]).toContain(`"file_id":"${fileId}"`) + expect(bodies[3]).toContain(`"file_id":"${uploads[1]}"`) + expect(bodies[3]).not.toContain('"type":"base64"') }) - it('accepts the session-log and plugin-package request extension fields', async () => { - const ctx = new Context() - contexts.push(ctx) + it.each([false, true])('submits Loader package inventory and records HTTP acceptance with session-log enabled=%s', async (enabled) => { + const ctx = await boot() await ctx.plugin(Loader) await ctx.plugin(AgentRegistry) - await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(SessionLogDeepSeek, { enabled: true }) - await ctx.plugin(PluginPackageInventoryDeepSeek) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL, thinking: 'disabled' }) - const session = ctx.sessions.create(SessionId('real-extension-fields')) + await ctx.plugin(SessionLogDeepSeek, enabled ? {} : { enabled: false }) + ctx.baseUrl = import.meta.url + // Select the source module while Loader owns its active package entry. + ctx.loader.internal = sourceModuleLoader(async (specifier) => { + if (specifier !== '@deepseek-ai/dsh-plugin-package-inventory-deepseek') throw new Error(`unexpected Loader import: ${specifier}`) + return PluginPackageInventoryDeepSeek + }) + await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' }) + await ctx.loader.await() + const packagePath = createRequire(import.meta.url).resolve('@deepseek-ai/dsh-plugin-package-inventory-deepseek/package.json') + const packageIdentity = JSON.parse(await readFile(packagePath, 'utf8')) as { name: string; version: string } + const session = ctx.sessions.create(SessionId(`real-messages-extensions-${randomUUID()}`)) session.append('turn/start', { turn: 1 }) - - const result = await assemble(ctx, { - model: FLASH, - messages: ask('Reply with exactly the word: pong'), - maxTokens: 50, - sessionId: session.id, + const fetchImpl = globalThis.fetch + let afterSeq: number = -1 + let throughSeq = 0 + let requests = 0 + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url !== `${Messages.PUBLIC_BASE_URL}/v1/messages`) return fetchImpl(input, init) + if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') + const body = JSON.parse(init.body) as Record + expect(body).toMatchObject({ dsh_plugin_packages: { + version: 1, packages: [{ name: packageIdentity.name, version: packageIdentity.version }], + } }) + if (enabled) { + expect(body).toMatchObject({ dsh_session_log: { + version: 1, sessionFormatVersion: session.header.version, session: { id: session.id }, + afterSeq, throughSeq, + events: Array.from({ length: throughSeq - afterSeq }, (_, index) => ({ seq: afterSeq + index + 1 })), + } }) + } else expect(body).not.toHaveProperty('dsh_session_log') + expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq) + const response = await fetchImpl(input, init) + expect(response.ok).toBe(true) + expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq) + requests += 1 + return response }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('pong') - expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0) - }) - - 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') - const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) - try { - // JSON.stringify quotes the value: YAML is a JSON superset, so a real - // key survives whatever characters it happens to carry. - await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) - // Scrub the ambient variable so only the credential seam can supply the - // key: this request proves the per-request resolution path end to end. - vi.stubEnv('DEEPSEEK_API_KEY', '') - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(LlmRuntime) - await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: LlmDeepSeek.PUBLIC_BASE_URL }) - - const result = await assemble(ctx, { - model: FLASH, - messages: ask('Reply with exactly the word: pong'), - maxTokens: 50, - }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('pong') - } finally { - vi.unstubAllEnvs() - await rm(dir, { recursive: true, force: true }) - } - }) - - it('flash dynamically switches from off to low', async () => { - const ctx = await harness(FLASH, { reasoningEffort: 'off' }) - const withoutThinking = await assemble(ctx,{ - model: FLASH, - messages: ask('Reply with exactly the word: pong'), - maxTokens: 50, - }) - expect(withoutThinking.finish.kind).toBe('stop') - expect(textOf(withoutThinking).toLowerCase()).toContain('pong') - expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false) - expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0) - expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0) - - const withThinking = await assemble(ctx,{ - model: FLASH, - reasoningEffort: ReasoningEffortId('low'), - messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), - maxTokens: 2000, - }) - expect(withThinking.finish.kind).toBe('stop') - expect(withThinking.message.content.some(block => block.type === 'reasoning')).toBe(true) - expect(textOf(withThinking)).toContain('9.8') - expect(withThinking.usage?.reasoningTokens).toBeGreaterThan(0) - }) - - it( - 'flash + thinking enabled (effort max): tool-call round trip with reasoning passback', - async () => { - const ctx = await harness(FLASH, { thinking: 'enabled' }) - - // Turn 1: the model must call the tool (and think before it). - const first = await assemble(ctx,{ - model: FLASH, - reasoningEffort: ReasoningEffortId('max'), - messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), - tools: [weatherTool], - maxTokens: 2000, - }) - expect( - first.finish.kind, - `DeepSeek Flash tool-call turn finished as ${JSON.stringify(first.finish)}`, - ).toBe('tool-calls') - const call = first.message.content.find(block => block.type === 'tool-call') - expect(call).toBeDefined() - expect(call!.name).toBe('get_weather') - expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string }) - - // Turn 2: send the tool result back WITH the assistant's reasoning - // block in history (the official thinking+tools passback rule). - const second = await assemble(ctx,{ - model: FLASH, - reasoningEffort: ReasoningEffortId('max'), - messages: [ - ...ask('What is the weather in Paris right now? Use the get_weather tool.'), - createMessage({ - role: 'assistant', content: first.message.content, - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - createToolResultMessage({ - callId: ToolCallId(call!.id), - content: [{ type: 'text', text: 'Sunny, 22°C' }], - isError: false, - }), - ], - tools: [weatherTool], - maxTokens: 2000, - }) - expect( - second.finish.kind, - `DeepSeek Flash tool-result turn finished as ${JSON.stringify(second.finish)}`, - ).toBe('stop') - expect(textOf(second).toLowerCase()).toMatch(/sunny|22/) - }, - ) - - it.skipIf(IN_HISTORY_MODEL === undefined)( - 'an in-history model follows a mid-history system message and keeps the cached prefix', - async () => { - const model = IN_HISTORY_MODEL as string - const ctx = await harness(model, { - thinking: 'disabled', - models: [{ id: model, systemPromptUpdate: 'in-history' }], - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', model)) - .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) - const system = (text: string) => createSystemMessage(text) - // A nonce before the padding isolates the provider cache across runs and retries. - const nonce = randomBytes(16).toString('hex') - const padding = Array.from({ length: 40 }, (_, index) => `Rule ${String(index + 1)}: keep every answer short and factual.`).join('\n') - // Both update strategies use identical prompt bytes, with the changed instruction near the head. - const prompt = (word: string) => `Test ${nonce}. When the user says ping, reply with exactly the word: ${word}\n${padding}` - const initial = prompt('pong') - const latest = prompt('banana') - const history = [system(initial), ...ask('ping')] - const wireRequests: WireMessage[][] = [] - const nativeFetch = globalThis.fetch - const observedFetch: typeof fetch = async (input, init) => { - if (init?.method !== 'POST' || typeof init.body !== 'string') return nativeFetch(input, init) - const body = JSON.parse(init.body) as WireRequest - if (body.model === model) { - wireRequests.push(body.messages) - if (wireRequests.length === 1) { - expect(body.messages).toEqual([ - { role: 'system', content: initial }, { role: 'user', content: 'ping' }, - ]) - } else if (wireRequests.length === 2) { - expect(JSON.stringify(body.messages)).toBe(JSON.stringify(wireRequests[0])) - } else if (wireRequests.length === 3) { - expect(JSON.stringify(body.messages.slice(0, history.length))) - .toBe(JSON.stringify(wireRequests[0])) - expect(body.messages[3]).toEqual({ role: 'system', content: latest }) - } else if (wireRequests.length === 4) { - expect(body.messages[0]).toEqual(wireRequests[2]![3]) - expect(body.messages.slice(1)).toEqual([ - wireRequests[2]![1], wireRequests[2]![2], wireRequests[2]![4], - ]) - } - } - return nativeFetch(input, init) + for (let run = 0; run < 2; run++) { + afterSeq = SessionLogDeepSeek.acceptedThrough(session) + throughSeq = Number(session.seq) - 1 + const assembler = new BlockAssembler() + for await (const chunk of ctx.llm.stream(options({ sessionId: session.id, reasoningEffort: ReasoningEffortId('off'), messages: [user('Reply with exactly PONG.')] }))) { + expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(enabled ? throughSeq : -1) + assembler.push(chunk) } - vi.stubGlobal('fetch', observedFetch) - try { - const first = await assemble(ctx, { model, messages: history, maxTokens: 50 }) - expect(first.finish.kind).toBe('stop') - expect(textOf(first).trim()).toBe('pong') - const initialTokens = first.usage?.inputTokens ?? 0 - expect(initialTokens).toBeGreaterThan(0) - - // Measure reusable tokens rather than assuming a provider cache-block size. - const warm = await assemble(ctx, { model, messages: history, maxTokens: 50 }) - expect(warm.finish.kind).toBe('stop') - expect(textOf(warm).trim()).toBe('pong') - const reusableTokens = warm.usage?.cacheReadTokens ?? 0 - expect(reusableTokens).toBeLessThanOrEqual(initialTokens) - expect(reusableTokens).toBeGreaterThan(0) - const assistant = createMessage({ - role: 'assistant', content: first.message.content, source: first.message.source, - }) - - const updated = await assemble(ctx, { - model, - messages: [...history, assistant, system(latest), ...ask('ping')], - maxTokens: 50, - }) - expect(updated.finish.kind).toBe('stop') - expect(textOf(updated).trim()).toBe('banana') - - const replaced = await assemble(ctx, { - model, - messages: [system(latest), ...ask('ping'), assistant, ...ask('ping')], - maxTokens: 50, - }) - expect(replaced.finish.kind).toBe('stop') - expect(textOf(replaced).trim()).toBe('banana') - expect(wireRequests).toHaveLength(4) - - const cached = updated.usage?.cacheReadTokens ?? 0 - expect(replaced.usage?.cacheReadTokens).toBeDefined() - const baselineCached = replaced.usage?.cacheReadTokens ?? 0 - expect(cached).toBeGreaterThanOrEqual(reusableTokens) - expect(cached).toBeGreaterThan(baselineCached) - } finally { - vi.stubGlobal('fetch', nativeFetch) - } - }, - ) - - it('streams raw chunks in protocol order', async () => { - const ctx = await harness(FLASH, { thinking: 'disabled' }) - const kinds: string[] = [] - for await (const chunk of ctx.llm.stream({ - provider: 'deepseek-official', - model: FLASH, - messages: ask('Count from 1 to 5, digits only.'), - maxTokens: 50, - })) { - kinds.push(chunk.type) + expect(assembler.finish.kind).toBe('stop') + expect(assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG') + expect(requests).toBe(run + 1) + if (run === 0) session.append('step/start', { turn: 1, step: 1 }) } - expect(kinds[0]).toBe('block-start') - expect(kinds.at(-1)).toBe('finish') - expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1) - // usage precedes finish (deferred-emit contract) - expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish')) + expect(session.snapshotEvents().filter(event => event.type === 'session-log-deepseek/delivery-accepted')).toHaveLength(enabled ? 2 : 0) + }) + + it.each(['off', 'low', 'high', 'max'])('streams text with %s effort', async (effort) => { + const ctx = await boot() + const result = await assemble(ctx.llm.stream(options({ reasoningEffort: ReasoningEffortId(effort), temperature: 0, messages: [user('Reply with exactly PONG.')] }))) + expect(result.assembler.finish).toEqual({ kind: 'stop' }) + expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG') + expect(result.assembler.usage?.outputTokens).toBeGreaterThan(0) + expect(result.message.source.replayState).toBeDefined() + }) + + it('replays a JSON-persisted thinking/tool turn and an error result before continuing', async () => { + const ctx = await boot() + const history: Message[] = [user('Use lookup_value with key secret. If the tool returns an error, report its exact error text and stop.')] + const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') }) + const first = await assemble(ctx.llm.stream(request)) + expect(first.assembler.finish.kind).toBe('tool-calls') + const calls = first.message.content.filter(block => block.type === 'tool-call') + expect(calls).toHaveLength(1) + const restored = JSON.parse(JSON.stringify(first.message)) as Message + history.push(restored) + for (const call of calls) history.push(createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'LOOKUP_DENIED_731' }], isError: true })) + const second = await assemble(ctx.llm.stream({ ...request, messages: history })) + expect(second.assembler.finish.kind).toBe('stop') + expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('LOOKUP_DENIED_731') + history.push(second.message, user('Reply with exactly DONE.')) + const third = await assemble(ctx.llm.stream({ ...request, messages: history })) + expect(third.assembler.finish.kind).toBe('stop') + }) + + it('continues persisted foreign history containing malformed tool arguments', async () => { + const ctx = await boot() + const callId = ToolCallId('historical_lookup') + const history = [ + user('Look up the secret value.'), + createAssistantMessage({ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, content: [ + { type: 'tool-call', id: callId, name: 'lookup_value', arguments: '{"key":"the "secret""}' }, + ] }), + createToolResultMessage({ callId, content: [{ type: 'text', text: 'Invalid arguments: expected an object' }], isError: true }), + user('Do not retry the lookup. Reply with exactly HISTORY_RECOVERED_731.'), + ] + const saved = JSON.stringify(history) + const restored = JSON.parse(saved) as Message[] + const response = await assemble(ctx.llm.stream(options({ messages: restored, tools: [tool], reasoningEffort: ReasoningEffortId('off') }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('HISTORY_RECOVERED_731') + expect(JSON.stringify(restored)).toBe(saved) + }) + + it('cancels an active stream without committing a successful response', async () => { + const ctx = await boot() + const controller = new AbortController() + const stream = ctx.llm.stream(options({ signal: controller.signal, messages: [user('List the integers from one to one thousand, one per line.')] })) + let cancelled = false + let finish: unknown + for await (const chunk of stream) { + if (!cancelled && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')) { cancelled = true; controller.abort() } + if (chunk.type === 'finish') finish = chunk.reason + } + expect(cancelled).toBe(true) + expect(finish).toMatchObject({ kind: 'aborted' }) + }) + + it('continues a thinking/tool turn after degrading unusable persisted replay metadata', async () => { + const ctx = await boot() + const warnings: unknown[][] = [] + ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) + const history: Message[] = [user('Use lookup_value with key secret. Then reply with the exact tool result and stop.')] + const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') }) + const first = await assemble(ctx.llm.stream(request)) + expect(first.assembler.finish.kind).toBe('tool-calls') + const calls = first.message.content.filter(block => block.type === 'tool-call') + expect(calls).toHaveLength(1) + const restored = JSON.parse(JSON.stringify(first.message)) as typeof first.message + restored.source.replayState = { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] } + const saved = JSON.stringify(restored) + history.push(restored, ...calls.map(call => createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'REPLAY_RECOVERED_731' }], isError: false }))) + const second = await assemble(ctx.llm.stream({ ...request, messages: history })) + expect(second.assembler.finish.kind).toBe('stop') + expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('REPLAY_RECOVERED_731') + expect(warnings).toEqual([[expect.stringContaining('unsupported kind or version')]]) + expect(JSON.stringify(restored)).toBe(saved) }) }) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ea6ba2bf8f..f12fe10e87 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,2358 +1,448 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' +/** HTTP lifecycle, routing and optional Cordis services under real composition. */ +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from '@deepseek-ai/cordis' -import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import LlmRuntime, { ToolCallId, createUserMessage, - CONTEXT_WINDOW_EXCEEDED_CODE, - createToolResultMessage, - LlmError, - ProviderRequestId, - QUOTA_EXCEEDED_CODE, - ReasoningEffortId, - userAgent, -} from '@deepseek-ai/dsh-llm' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { SessionId } from '@deepseek-ai/dsh-session' -import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' -import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import { httpErrorCode } from '../src/protocols/chat-completions/adapter.ts' -import { resolveRequestImageTarget } from '../src/common/request-pricing.ts' -import { assemble } from './assemble.ts' -import { closeMockServers, mockServer, textEvents } from './mock-server.ts' -import type { Behavior } from './mock-server.ts' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, LoggerLevel, Service } from '@deepseek-ai/cordis' +import LocalAttachments from '@deepseek-ai/dsh-attachment-local' +import AgentRegistry, { installModelSelection } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import LlmRuntime, { createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import LocalCredentials from '@deepseek-ai/dsh-credentials-local' +import FileSettings from '@deepseek-ai/dsh-settings-file' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { DeepSeekAdapter } from '../src/adapter.ts' +import { object } from '../src/replay.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' +import * as Messages from '../src/index.ts' +import { adapter, assemble, chunks, MODEL, options, prepareExtensions, server, sse, textEvents, user, sourceModuleLoader } from './helpers.ts' + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + while (cleanup.length) await cleanup.pop()!() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) +async function endpoint(...args: Parameters) { + const instance = await server(...args) + cleanup.push(() => instance.close()) + return instance +} +async function context() { + const home = await mkdtemp(join(tmpdir(), 'dsh-messages-test-')) + cleanup.push(() => rm(home, { recursive: true, force: true })) + vi.stubEnv('DSH_HOME', home) + const ctx = new Context() + cleanup.push(() => ctx.fiber.dispose()) + return { ctx, home } +} + +async function send(agent: Agent, text: string) { + agent.followup(user(text)) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) +} declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - 'test': { kind: 'test' } & ContextFormed + 'saved-notice': { kind: 'saved-notice' } } } -const TEST_USER_ID = '00000000-0000-4000-8000-000000000001' as AnonymousUserId -let testHome: string +describe('direct Messages HTTP', () => { + it('continues through Messages with assistant blocks in saved user history', async () => { + const http = await endpoint() + const notice = createUserMessage({ source: { kind: 'saved-notice' }, content: [ + { type: 'text', text: 'Background subagent finished.' }, + { type: 'reasoning', text: 'child reasoning' }, + { type: 'tool-call', id: ToolCallId('child-call'), name: 'read', arguments: '{}' }, + { type: 'text', text: 'Its closing message: answer.' }, + ] }) + const empty = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'reasoning', text: 'no closing text' }] }) + const history = [notice, empty] + const saved = JSON.stringify(history) + const llm = adapter({ baseURL: http.url }) + const first = await assemble(llm.stream(options({ messages: history }))) + const second = await assemble(llm.stream(options({ messages: [...history, first.message, user('continue')] }))) -beforeEach(() => { - testHome = mkdtempSync(join(tmpdir(), 'dsh-llm-deepseek-')) - vi.stubEnv('DSH_HOME', testHome) -}) - -afterEach(async () => { - await closeMockServers() - vi.unstubAllEnvs() - vi.useRealTimers() - rmSync(testHome, { recursive: true, force: true }) -}) - -async function harness(baseURL: string, config: object = {}) { - // Configuration carries only the reference; the key comes from the - // environment, which is the whole credential plane without a mounted seam. - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL, ...config }) - return ctx -} - -/** Direct adapter over the plugin's real resolve step, with a static key. */ -function noExtensions(): Promise { - return Promise.resolve({ fields: {}, accept: () => Promise.resolve() }) -} - -/** Direct adapter over the plugin's real resolve step, with a static key. */ -function adapterOf( - config: Partial & { apiKey?: string } = {}, - attachments?: AttachmentStore, - files?: LlmDeepSeek.DeepSeekFileStore, -): DeepSeekAdapter { - const { apiKey, ...rest } = config - return new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', ...rest }), - resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), - resolveUserId: () => TEST_USER_ID, - resolveAttachments: () => attachments, - ...files === undefined ? {} : { resolveFiles: () => files }, - prepareExtensions: noExtensions, - }) -} - -async function drain(stream: AsyncIterable): Promise { - for await (const _chunk of stream) { /* drain */ } -} - -const imageRef: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', - bytes: 3, - width: 1, - height: 1, -} - -function requestImage(ref = imageRef): RequestImageAttachment { - return { - variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), - attachment: ref, - data: Uint8Array.of(1, 2, 3), - mediaType: 'image/png', - bytes: 3, - width: 1, - height: 1, - depth: 'uchar', - space: 'srgb', - hasAlpha: true, - } -} - -function attachmentStoreOf( - project: (ref: ImageAttachmentRef, policy: unknown, signal?: AbortSignal) => Promise, -): { - store: AttachmentStore - readImageRequest: ReturnType> -} { - const readImageRequest = vi.fn(project) - return { - store: { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore, - readImageRequest, - } -} - -function fileStoreOf( - implementation: (...args: Parameters) => ReturnType, -) { - const ensureUploaded = vi.fn(implementation) - const invalidate = vi.fn(() => Promise.resolve()) - return { - store: { ensureUploaded, invalidate } as unknown as LlmDeepSeek.DeepSeekFileStore, - ensureUploaded, - invalidate, - } -} - -function fileReference(fileId: string): Awaited> { - return { - record: { fileId: LlmDeepSeek.DeepSeekFileId(fileId) }, - uploaded: true, - } as Awaited> -} - -function successfulSseResponse(): Response { - return new Response(textEvents.map(event => `data: ${event}\n\n`).join(''), { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) -} - -describe('request image target', () => { - it.each([ - [ - { id: 'default' }, - { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 }, - ], - [ - { id: 'low', imagePixelBudget: 'low' as const }, - { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 }, - ], - [ - { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, - { width: 565, height: 565, maxBytes: 512_000 }, - ], - ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => { - expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected) - }) - - it('caps every request image at the provider per-side limit', () => { - expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 })) - .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 }) - expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 })) - .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 }) - }) - - it('answers image request pricing from the current connection snapshot', () => { - const adapter = adapterOf({ - models: [{ id: 'vision', inputModalities: ['text', 'image'] }], - }) - const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(priced).toHaveLength(1) - expect(priced?.[0]!.visualTokens).toBeGreaterThan(0) - const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(textOnly?.[0]!.visualTokens).toBe(0) - }) - - it('prices descriptor text through the serializer\'s access resolution', () => { - const attachments = {} as AttachmentStore - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', models: [{ id: 'vision', inputModalities: ['text', 'image'] }] }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - resolveAttachments: () => attachments, - resolveImageAccess: (store, ref) => (store === attachments && ref === imageRef - ? { readonlyPath: '/world/img.png' } - : undefined), - prepareExtensions: noExtensions, - }) - const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) - expect(priced?.[0]?.text).toContain('/world/img.png') - }) -}) - -describe('DeepSeekAdapter against a mock server', () => { - it('merges prepared extension fields and accepts them once after HTTP 2xx', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const accept = vi.fn() - const prepareExtensions = vi.fn(async () => ({ - fields: { dsh_test: { version: 1 } }, - accept: async () => { accept() }, - })) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: prepareExtensions as never, - }) - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [], sessionId: SessionId('s') })) - expect(server.requests[0]).toMatchObject({ dsh_test: { version: 1 } }) - expect(prepareExtensions).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 's' })) - expect(accept).toHaveBeenCalledOnce() - }) - - it('fails before fetch on extension preparation or base-field collision', async () => { - const server = await mockServer([]) - const base = { - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - } - const failed = new DeepSeekAdapter({ - ...base, - prepareExtensions: () => Promise.reject(new Error('metadata unavailable')), - }) - await expect(drain(failed.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) - - const collision = new DeepSeekAdapter({ - ...base, - prepareExtensions: (() => Promise.resolve({ fields: { model: 'replacement' }, accept: () => Promise.resolve() })) as never, - }) - await expect(drain(collision.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) - expect(server.requests).toHaveLength(0) - }) - - it('passes cancellation into extension preparation and aborts before fetch', async () => { - const server = await mockServer([]) - const controller = new AbortController() - const started = Promise.withResolvers() - let signalSeen: AbortSignal | undefined - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: ((request: { signal: AbortSignal }) => { - signalSeen = request.signal - started.resolve(undefined) - if (request.signal === undefined) return new Promise(() => {}) - return new Promise((_resolve, reject) => { - request.signal.addEventListener('abort', () => { - const reason: unknown = request.signal.reason - reject(reason instanceof Error ? reason : new Error('extension preparation aborted', { cause: reason })) - }, { once: true }) - }) - }) as never, - }) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'm', - messages: [], - signal: controller.signal, - })) - await started.promise - expect(signalSeen).toBeInstanceOf(AbortSignal) - controller.abort() - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - expect(server.requests).toHaveLength(0) - }) - - it('passes cancellation through an outstanding fetch', async () => { - const controller = new AbortController() - const started = Promise.withResolvers() - const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { - const signal = init?.signal - return new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { - const reason: unknown = signal.reason - reject(reason instanceof Error ? reason : new Error('fetch aborted', { cause: reason })) - }, { once: true }) - }) - }) - try { - const adapter = adapterOf({ baseURL: 'https://provider.invalid' }) - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'm', - messages: [], - signal: controller.signal, - })) - await started.promise - controller.abort() - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - } finally { - fetch.mockRestore() - } - }) - - it('does not accept extensions on non-2xx and does accept before a later stream failure', async () => { - const server = await mockServer([ - { kind: 'http-error', status: 500, body: '{}' }, - { kind: 'close-early', events: ['{"choices":[{"delta":{"content":"partial"}}]}'] }, + expect(first.assembler.finish.kind).toBe('stop') + expect(second.assembler.finish.kind).toBe('stop') + expect(http.requests).toHaveLength(2) + const wireNotice = { role: 'user', content: [ + { type: 'text', text: 'Background subagent finished.' }, + { type: 'text', text: 'Its closing message: answer.' }, + ] } + expect(http.requests[0]?.body.messages).toEqual([wireNotice]) + expect(http.requests[1]?.body.messages).toEqual([ + wireNotice, + { role: 'assistant', content: [{ type: 'text', text: 'Hello 世界' }] }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, ]) - const accept = vi.fn() - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: () => Promise.resolve({ fields: { dsh_test: 1 }, accept: async () => { accept() } }) as never, - }) - const request = { provider: 'deepseek-official', model: 'm', messages: [] } + expect(JSON.stringify(history)).toBe(saved) + }) - await expect(drain(adapter.stream(request))).rejects.toMatchObject({ code: 'SERVER' }) + it('continues without a diagnostic callback when replay metadata is unusable', async () => { + const http = await endpoint() + const message = createAssistantMessage({ content: [{ type: 'text', text: 'Remember 731.' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: { response: {}, blocks: [] }, + } }) + const response = await assemble(adapter({ baseURL: http.url }).stream(options({ messages: [user(), message, user()] }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.body.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Remember 731.' }] }, + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + ]) + }) + + it('uses the Messages endpoint, authentication, attribution and final usage', async () => { + const http = await endpoint() + const llm = adapter({ baseURL: http.url }) + const response = await assemble(llm.stream(options({ model: 'deepseek-flash', sessionId: SessionId('session-test'), purpose: 'compaction' })), 'deepseek-flash') + expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) + expect(response.message.source).toMatchObject({ + model: 'deepseek-flash', replayState: { response: { model: 'deepseek-flash' } }, + }) + expect(http.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { + 'x-api-key': 'test-key', 'anthropic-version': '2023-06-01', + 'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user', + 'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1', + }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } }) + expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' }) + expect((await llm.listModels('deepseek-official')).map(model => model.id)).toEqual([ + 'deepseek-flash', 'deepseek-v4-pro', + ]) + expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({ + name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', + }) + expect(await llm.resolveModel('deepseek-official', MODEL)).toMatchObject({ id: MODEL }) + expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined() + }) + + it.each([ + ['https://provider.example', 'https://provider.example/v1/messages'], + ['https://provider.example/v1/', 'https://provider.example/v1/messages'], + ['https://provider.example/v1beta', 'https://provider.example/v1beta/v1/messages'], + ['https://provider.example/v2', 'https://provider.example/v2/v1/messages'], + ['https://provider.example/anthropic', 'https://provider.example/anthropic/v1/messages'], + ['https://v1.provider.example', 'https://v1.provider.example/v1/messages'], + ])('resolves the Messages endpoint from %s', async (baseURL, expected) => { + const fetchImpl = vi.fn(async () => new Response(sse(textEvents), { + headers: { 'content-type': 'text/event-stream' }, + })) + vi.stubGlobal('fetch', fetchImpl) + + await chunks(adapter({ baseURL }).stream(options())) + + expect(fetchImpl.mock.calls[0]?.[0]).toBe(expected) + }) + + it.each([true, false])('maps non-2xx responses (JSON=%s)', async (json) => { + const http = await endpoint((response) => { response.statusCode = 429; response.setHeader('retry-after', '3'); response.end(json ? JSON.stringify({ error: { type: 'rate_limit_error', message: 'slow down' } }) : 'busy') }) + await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } }) + }) + + it('refuses redirects before credentials reach another origin or request extensions are accepted', async () => { + const destination = await endpoint() + const source = await endpoint((response) => { + response.writeHead(307, { location: `${destination.url}/v1/messages` }) + response.end() + }) + const accept = vi.fn(async () => {}) + const prepare = vi.fn(async () => ({ fields: {}, accept })) + const files = new DeepSeekFileStore() + const llm = new DeepSeekAdapter({ + options: () => Messages.resolveAdapterOptions({ baseURL: source.url }), + resolveApiKey: () => Promise.resolve('test-key'), resolveUserId: () => 'test-user' as AnonymousUserId, + resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, + prepareExtensions: prepare, + }) + const error = await chunks(llm.stream(options())).catch((cause: unknown) => cause) + expect(source.requests).toHaveLength(1) + expect(source.requests[0]?.headers['x-api-key']).toBe('test-key') + expect(destination.requests).toEqual([]) + expect(error).toMatchObject({ code: 'TRANSPORT' }) + expect(prepare).toHaveBeenCalledOnce() expect(accept).not.toHaveBeenCalled() - await expect(drain(adapter.stream(request))).rejects.toBeDefined() - expect(accept).toHaveBeenCalledOnce() }) - it('reports a post-2xx extension acceptance failure without relabelling it as transport', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const failure = new Error('watermark append failed') - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: () => Promise.resolve({ - fields: { dsh_test: 1 }, - accept: () => Promise.reject(failure), - }) as never, + it('freezes endpoint and defaults for a prepared call while the next call sees new settings', async () => { + const first = await endpoint(), second = await endpoint() + let config = Messages.resolveAdapterOptions({ baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) + const files = new DeepSeekFileStore() + const llm = new DeepSeekAdapter({ options: () => config, resolveApiKey: snapshot => Promise.resolve(snapshot.maxTokens === 10 ? 'first' : 'second'), resolveUserId: () => 'user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) + const prepared = await llm.prepareCall('deepseek-official', MODEL) + config = Messages.resolveAdapterOptions({ baseURL: second.url, maxTokens: 20 }) + expect(prepared.model.systemPromptUpdate).toBe('in-history') + expect((await llm.resolveModel('deepseek-official', MODEL)).systemPromptUpdate).toBeUndefined() + await chunks(prepared.stream(options())) + await chunks(llm.stream(options())) + expect(first.requests[0]).toMatchObject({ headers: { 'x-api-key': 'first' }, body: { max_tokens: 10 } }) + expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'second' }, body: { max_tokens: 20 } }) + }) + + it('aborts an open provider response when its consumer stops', async () => { + let closed!: () => void + const stopped = new Promise((resolve) => { closed = resolve }) + const http = await endpoint((response) => { + response.once('close', closed) + response.write(sse(textEvents.slice(0, 3))) }) - - await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) - .rejects.toMatchObject({ code: 'REQUEST_EXTENSION', cause: failure }) - expect(server.requests).toHaveLength(1) + const stream = adapter({ baseURL: http.url }).stream(options())[Symbol.asyncIterator]() + expect((await stream.next()).value).toMatchObject({ type: 'block-start' }) + await stream.return!() + await stopped }) - it('streams a text generation end to end through the assembler', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - const result = await assemble(ctx, { - model: 'deepseek-v4-pro', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(result.finish).toEqual({ kind: 'stop' }) - expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1, totalTokens: 4 }) - - // The wire request carried the auth header contents we configured. - expect(server.requests[0]).toMatchObject({ - model: 'deepseek-v4-pro', - max_tokens: 256_000, - reasoning_effort: 'high', - stream: true, - stream_options: { include_usage: true }, - }) - // App attribution and DeepSeek request identity are independent wire facts. - expect(server.headers[0]?.['user-agent']).toBe(userAgent()) - expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) - expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-session-id') - expect(server.headers[0]).not.toHaveProperty('http-referer') - expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') - expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') - expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') + it('distinguishes caller cancellation from idle timeout and transport failure', async () => { + const http = await endpoint((response) =>{ response.flushHeaders() }) + await expect(chunks(adapter({ baseURL: http.url, streamIdleTimeoutMs: 30 }).stream(options()))).rejects.toMatchObject({ code: 'TIMEOUT' }) + const controller = new AbortController(); controller.abort() + await expect(chunks(adapter({ baseURL: http.url }).stream(options({ signal: controller.signal })))).rejects.toMatchObject({ code: 'ABORTED' }) + vi.stubGlobal('fetch', async () => { throw new TypeError('network down') }) + await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'TRANSPORT' }) }) - it('uploads a durable image once and sends only its Files API id to the vision model', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const signalSeen: (AbortSignal | undefined)[] = [] - const policies: unknown[] = [] - const attachmentMocks = attachmentStoreOf((ref, policy, signal) => { - signalSeen.push(signal) - policies.push(policy) - return Promise.resolve(requestImage(ref)) - }) - const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-flash', - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'describe ' }, - { type: 'image', attachment: imageRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.requests[0]).toMatchObject({ - model: 'deepseek-flash', - messages: [{ - role: 'user', - content: [ - { type: 'text', text: 'describe ' }, - { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, - { type: 'file', file_id: 'file-api-1' }, - ], - }], - }) - expect(server.fileRequests).toEqual([{ - method: 'POST', - path: '/files', - filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, - bytes: 3, - }]) - expect(signalSeen[0]).toBeInstanceOf(AbortSignal) - expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) - }) - - it('keeps images inside plugin content opaque through adapter.stream', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) - await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', messages: [ - createUserMessage({ source: { kind: 'user' }, content: [ - { type: 'plugin:message', - message: { role: 'tool', toolCallId: 'foreign', content: [{ type: 'image', attachment: { ...imageRef } }] }, - } as never, - { type: 'text', text: 'context' }, - ] }), - ] })) - expect(attachmentMocks.readImageRequest).not.toHaveBeenCalled() - expect(server.fileRequests).toHaveLength(0) - expect(server.requests).toHaveLength(1) - expect(server.requests[0]).toMatchObject({ messages: [{ role: 'user', content: 'context' }] }) - }) - - it('falls back to one all-base64 request when Files API resolution fails', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - const body = server.requests[0] as { messages: Array<{ content: unknown }> } - expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image_url"/g)).toHaveLength(2) - expect(JSON.stringify(body)).not.toContain('file_id') - expect(files.ensureUploaded).toHaveBeenCalledTimes(1) - }) - - it('fails a base64 fallback whose retained images exceed the inline bound with the count to offload', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) - const adapter = adapterOf({ - baseURL: server.url, - maxInlineRequestImageBytes: 80, - inlineImageOffloadByteQuantum: 40, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: imageRef })), - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', message: expect.stringContaining('11 more') as string }) - expect(server.requests).toHaveLength(0) - }) - it('discards partially resolved file ids and falls back with every retained image inline', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const files = fileStoreOf(() => Promise.reject(new Error('unused'))) - files.ensureUploaded - .mockResolvedValueOnce(fileReference('file-api-partial')) - .mockRejectedValueOnce(new LlmError('Files unavailable', 'TRANSPORT')) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - const body = server.requests[0] as { messages: Array<{ content: unknown }> } - expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image_url"/g)).toHaveLength(2) - expect(JSON.stringify(body)).not.toContain('file-api-partial') - }) - - it('falls back after the configured Files API deadline without aborting chat', async () => { - vi.useFakeTimers() - const started = Promise.withResolvers() - const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { - const reason: unknown = signal.reason - reject(reason instanceof Error ? reason : new Error('files operation aborted')) - }, { once: true }) - })) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulSseResponse()) - const adapter = adapterOf({ - baseURL: 'https://deepseek.invalid', - filesApiTimeoutMs: 50, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - await started.promise - await vi.advanceTimersByTimeAsync(50) - await pending - - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy.mock.calls[0]?.[1]?.body).toEqual(expect.stringContaining('image_url')) - fetchSpy.mockRestore() - }) - - it('does not turn caller cancellation during file resolution into base64 fallback', async () => { - const started = Promise.withResolvers() - const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { - started.resolve(undefined) - signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) - })) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const fetchSpy = vi.spyOn(globalThis, 'fetch') - const controller = new AbortController() - const adapter = adapterOf({ - baseURL: 'https://deepseek.invalid', - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - const pending = drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - signal: controller.signal, - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - await started.promise - controller.abort() - - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) - expect(fetchSpy).not.toHaveBeenCalled() - fetchSpy.mockRestore() - }) - - it('does not retry a generic chat failure through base64 fallback', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 503, - body: JSON.stringify({ error: { message: 'chat unavailable' } }), - }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.resolve(fileReference('file-api-ready'))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'SERVER', message: 'chat unavailable' }) - - expect(server.requests).toHaveLength(1) - expect(JSON.stringify(server.requests[0])).toContain('file-api-ready') - expect(JSON.stringify(server.requests[0])).not.toContain('image_url') - }) - - it('does not prepare a surface-offloaded image and sends its placeholder instead', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } - const recent = { ...imageRef, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } - const attachmentMocks = attachmentStoreOf((ref) => { - if (ref.attachmentId === old.attachmentId) throw new Error('old image must not be read') - return Promise.resolve(requestImage(ref)) - }) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachmentMocks.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: old, offloaded: true }, - { type: 'image', attachment: recent }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( - recent, - { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, - expect.any(AbortSignal), - ) - const body = server.requests[0] as { messages: unknown[] } - expect(body.messages[0]).toMatchObject({ - role: 'user', - content: [ - { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, - { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, - { type: 'file', file_id: 'file-api-1' }, - ], - }) - }) - - it('projects nested tool-result images with route-owned request budgets', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ - baseURL: server.url, - models: [ - { - id: 'vision-low', - inputModalities: ['text', 'image'], - imagePixelBudget: 'low', - imageMaxBytes: 512_000, - }, - { - id: 'vision-custom', - inputModalities: ['text', 'image'], - imagePixelBudget: 320_000, - }, - ], - }, attachmentMocks.store) - const nested = createToolResultMessage({ - callId: ToolCallId('image-result'), - content: [{ type: 'image', attachment: imageRef }], - isError: false, - }) - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [nested] })) - await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [nested] })) - - expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( - 1, - imageRef, - { width: 1, height: 1, maxBytes: 512_000 }, - expect.any(AbortSignal), - ) - expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( - 2, - imageRef, - { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, - expect.any(AbortSignal), - ) - }) - - it('reuses the exact request version between agent and compaction calls', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - const messages = [createUserMessage({ - content: [{ type: 'image' as const, attachment: imageRef }], - source: { kind: 'test' as const }, - })] - - await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', messages })) - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages, - purpose: 'compaction', - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(1) - expect(server.requests).toMatchObject([ - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - ]) - expect(server.headers[1]?.['x-deepseek-harness-compact']).toBe('1') - }) - - it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => { - const raw = JSON.stringify({ error: { message: 'unsupported image payload for file-api-1' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - let failure: unknown - try { - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - } catch (error: unknown) { - failure = error - } - expect(failure).toMatchObject({ - code: 'INVALID_REQUEST', - message: expect.stringContaining( - `normalized image "${imageRef.attachmentId}" at message 1, image 1`, - ) as string, - cause: { message: raw }, - }) - expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1') - expect((failure as Error).message).toContain('unsupported image payload for file-api-1') - expect((failure as Error).message).not.toBe(raw) - }) - - it('identifies the sole image when a normalized rejection omits its file id', async () => { - const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ - message: expect.stringContaining(`normalized image "${imageRef.attachmentId}"`) as string, - }) - }) - - it('lists every candidate when a normalized multi-image rejection names no file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) - const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) - const attachments = attachmentStoreOf((ref) => { - const first = ref.attachmentId === imageRef.attachmentId - return Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), - attachment: first ? { ...ref, name: 'diagram.png' } : ref, - hasAlpha: false, - }) - }).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ - code: 'INVALID_REQUEST', - message: expect.stringContaining('Candidate images: "diagram.png"') as string, - cause: { message: raw }, - }) - }) - - it.each([ - 'file-api-1 expired', - 'file_id file-api-10 invalid; file_id file-api-1 expired', - 'file_id file-api-1 expired', - 'file_not_found', - 'file_id file-api-1 deleted', - 'invalid file_id file-api-1', - ])('reuploads once when chat rejects a Files API reference as %s', async (providerMessage) => { - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: providerMessage } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachmentMocks.store) - const options = { - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image' as const, attachment: imageRef }], - source: { kind: 'test' as const }, - })], - } - - await drain(adapter.stream(options)) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) - expect(server.requests).toMatchObject([ - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-1' }] }] }, - { messages: [{ content: [expect.objectContaining({ type: 'text' }), { file_id: 'file-api-2' }] }] }, - ]) - expect(attachmentMocks.readImageRequest).toHaveBeenCalledTimes(1) - }) - - it('uses inline fallback when stale-id recovery cannot resolve a replacement file', async () => { - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file_id file-api-stale expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const files = fileStoreOf(() => Promise.reject(new Error('unused'))) - files.ensureUploaded - .mockResolvedValueOnce(fileReference('file-api-stale')) - .mockRejectedValueOnce(new LlmError('Files unavailable', 'SERVER')) - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments, files.store) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(files.invalidate).toHaveBeenCalledTimes(1) - expect(server.requests).toHaveLength(2) - expect(JSON.stringify(server.requests[0])).toContain('file-api-stale') - expect(JSON.stringify(server.requests[1])).toContain('image_url') - }) - - it('invalidates only the identified mapping when a multi-image request names one stale file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file_id file-api-2 expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(3) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-2' }]) - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-1' }, { type: 'file', file_id: 'file-api-3' }]) - }) - - it('invalidates every listed missing file id and preserves unlisted mappings', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const thirdRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ - error: { - message: 'path.to.object[index]: the following file_ids do not exist or are not created under your account: ' - + 'file-api-1, file-api-3, file-api-unknown', - }, - }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf((ref) => { - let digest = 'f' - if (ref.attachmentId === imageRef.attachmentId) digest = 'b' - else if (ref.attachmentId === secondRef.attachmentId) digest = 'd' - return Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${digest.repeat(64)}`), - }) - }).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - { type: 'image', attachment: thirdRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(5) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([ - { type: 'file', file_id: 'file-api-1' }, - { type: 'file', file_id: 'file-api-2' }, - { type: 'file', file_id: 'file-api-3' }, - ]) - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([ - { type: 'file', file_id: 'file-api-4' }, - { type: 'file', file_id: 'file-api-2' }, - { type: 'file', file_id: 'file-api-5' }, - ]) - }) - - it('invalidates every used mapping when a stale-file response does not identify one file id', async () => { - const secondRef: ImageAttachmentRef = { - ...imageRef, - attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), - } - const server = await mockServer([ - { - kind: 'http-error', - status: 400, - body: JSON.stringify({ error: { message: 'file reference expired' } }), - }, - { kind: 'sse', events: textEvents }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve({ - ...requestImage(ref), - variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), - })).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: imageRef }, - { type: 'image', attachment: secondRef }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) - - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(4) - const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> - expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'file')) - .toEqual([{ type: 'file', file_id: 'file-api-3' }, { type: 'file', file_id: 'file-api-4' }]) - }) - - it('returns the second stale-file rejection without a third chat attempt', async () => { - const stale = JSON.stringify({ error: { message: 'file_id file-api-1 expired' } }) - const server = await mockServer([ - { kind: 'http-error', status: 400, body: stale }, - { kind: 'http-error', status: 400, body: stale }, - ]) - const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store - const adapter = adapterOf({ - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }, attachments) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'file_id file-api-1 expired' }) - expect(server.requests).toHaveLength(2) - expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) - }) - - it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( - 'rejects image input for text-only model %s before credentials, attachments, or fetch', - async (model) => { - const server = await mockServer([]) - const resolveApiKey = vi.fn(() => Promise.resolve('k')) - const resolveAttachments = vi.fn(() => ({}) as AttachmentStore) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url }), - resolveApiKey, - resolveUserId: () => TEST_USER_ID, - resolveAttachments, - prepareExtensions: noExtensions, - }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model, - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveApiKey).not.toHaveBeenCalled() - expect(resolveAttachments).not.toHaveBeenCalled() - expect(server.requests).toHaveLength(0) - }, - ) - - it('rejects vision input without an attachment provider before credentials or fetch', async () => { - const server = await mockServer([]) - const resolveApiKey = vi.fn(() => Promise.resolve('k')) - const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ - protocol: 'chat-completions', - baseURL: server.url, - models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], - }), - resolveApiKey, - resolveUserId: () => TEST_USER_ID, - prepareExtensions: noExtensions, - }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: imageRef }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveApiKey).not.toHaveBeenCalled() - expect(server.requests).toHaveLength(0) - }) - - it('streams raw chunks through ctx.llm.stream', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) - const ctx = await harness(server.url) - - const kinds: string[] = [] - for await (const chunk of ctx.llm.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - })) { - kinds.push(chunk.type) - } - expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) - }) - - it('forwards the harness user and session ids for host-side trajectory routing', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - sessionId: SessionId('child-session'), - }) - - expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') - expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) - }) - - it('marks the auxiliary compaction call on the wire', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) - - await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - purpose: 'compaction', - }) - - expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') - }) - - it('switches dynamically from the configured low default through off to max', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'low' }) - - await assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await assemble(ctx,{ - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('off'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi again' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await assemble(ctx,{ - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('max'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'one more time' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(server.requests[0]).toMatchObject({ - thinking: { type: 'enabled' }, - reasoning_effort: 'low', - }) - expect(server.requests[1]).toMatchObject({ - thinking: { type: 'disabled' }, - }) - expect(server.requests[1]).not.toHaveProperty('reasoning_effort') - expect(server.requests[2]).toMatchObject({ - thinking: { type: 'enabled' }, - reasoning_effort: 'max', - }) - }) - - it('uses the configured maxTokens default and preserves an explicit request cap', async () => { - const server = await mockServer([ - { kind: 'sse', events: textEvents }, - { kind: 'sse', events: textEvents }, - ]) - const ctx = await harness(server.url, { maxTokens: 32_000 }) - - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 }) - - expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 }) - expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 }) - }) - - it('publishes only off and omits the wire effort when thinking is disabled', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url, { thinking: 'disabled' }) - - await assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(server.requests[0]).toMatchObject({ - thinking: { type: 'disabled' }, - }) - expect(server.requests[0]).not.toHaveProperty('reasoning_effort') - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) - .resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it('reports a per-request effort failure before I/O when thinking is disabled', async () => { - const server = await mockServer([]) - const ctx = await harness(server.url, { thinking: 'disabled' }) - - const result = await assemble(ctx, { - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId('high'), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, - }) - expect(server.requests).toHaveLength(0) - }) - - it.each(['high', 'max'])( - 'rejects direct adapter effort %s before I/O when thinking is disabled', - async (effort) => { - const server = await mockServer([]) - const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) - - const stream = adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - reasoningEffort: ReasoningEffortId(effort), - messages: [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }) - await expect(async () => { - for await (const _chunk of stream) { /* drain */ } - }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) - expect(server.requests).toHaveLength(0) - }, - ) - - it.each([ - [401, 'AUTH'], - [403, 'AUTH'], - [429, 'RATE_LIMIT'], - [400, 'INVALID_REQUEST'], - [500, 'SERVER'], - [503, 'SERVER'], - ])('maps HTTP %d to failure code %s with the body message', async (status, code) => { - const behavior: Behavior = { - kind: 'http-error', - status, - body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), - } - const server = await mockServer([behavior]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { message: `failed with ${status}`, code, status }, - }) - }) - - it('uses the HTTP status as the cause when an error response has no body', async () => { - const server = await mockServer([{ kind: 'http-error', status: 500, body: '' }]) - const adapter = adapterOf({ baseURL: server.url }) - - await expect(drain(adapter.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [], - }))).rejects.toMatchObject({ - code: 'SERVER', - cause: { message: 'DeepSeek HTTP 500' }, - }) - }) - - it('classifies an HTTP context-window failure with the canonical code', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 400, - body: JSON.stringify({ - error: { - message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.', - type: 'invalid_request_error', - code: 'context_length_exceeded', - }, - }), - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, - }) - }) - - it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { - const server = await mockServer([{ - kind: 'http-error', - status: 429, - body: JSON.stringify({ error: { message: 'slow down' } }), - headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { - message: 'slow down', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 2_000, - requestId: ProviderRequestId('req-429'), - }, - }) - }) - - it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => { - const now = 1_800_000_000_000 - const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) - try { - const server = await mockServer([{ - kind: 'http-error', - status: 503, - body: JSON.stringify({ error: { message: 'come back later' } }), - headers: { - 'retry-after': new Date(now + 3_000).toUTCString(), - 'x-deepseek-request-id': 'deepseek-503', - }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { - message: 'come back later', - code: 'SERVER', - status: 503, - providerRetryAfterMs: 3_000, - requestId: ProviderRequestId('deepseek-503'), - }, - }) - } finally { - dateNow.mockRestore() - } - }) - - it('omits zero, non-finite, invalid, and past Retry-After values', async () => { - const values = [ - '0', - '9'.repeat(400), - 'not-a-date', - new Date(0).toUTCString(), - ] - for (const value of values) { - const server = await mockServer([{ - kind: 'http-error', - status: 429, - body: JSON.stringify({ error: { message: 'retry later' } }), - headers: { 'retry-after': value }, - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toEqual({ - kind: 'error', - failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 }, - }) - } - }) - - it('classifies only context-capacity HTTP 400 details as context overflow', () => { - expect(httpErrorCode(400, { message: 'request too large for model context' })) - .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) - expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' })) - .toBe('INVALID_REQUEST') - expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('INVALID_REQUEST') - }) - - it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { - expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' })) - .toBe(QUOTA_EXCEEDED_CODE) - expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT') - }) - - it('keeps the status-line message for JSON error bodies without a message', async () => { - const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('SERVER') - expect(result.finish.failure.message).toMatch(/HTTP 500/) - }) - - it('keeps the status-line message for non-JSON error bodies', async () => { - const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('SERVER') - expect(result.finish.failure.message).toMatch(/HTTP 502/) - }) - - it('maps unusual statuses to HTTP_', () => { - expect(httpErrorCode(418)).toBe('HTTP_418') - }) - - it('reports a transport failure with the endpoint in the message', async () => { - // Port 1 is reserved/unbound, so the service normalizes the fetch failure. - const ctx = await harness('http://127.0.0.1:1') - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ - kind: 'error', - failure: { - code: 'TRANSPORT', - message: 'DeepSeek API request to http://127.0.0.1:1 failed', - }, - }) - }) - - it('classifies an aborted request as an aborted finish', async () => { - const controller = new AbortController() - controller.abort() - const ctx = await harness('http://127.0.0.1:1') - const result = await assemble(ctx, { - model: 'deepseek-v4-flash', - messages: [], - signal: controller.signal, - }) - expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } }) - }) - - it('throws EMPTY_RESPONSE when the response has no body', async () => { - const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(null, { status: 200 }), - ) - try { - const iterate = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(iterate()).rejects.toThrow(/no response body/) - } finally { - fetchSpy.mockRestore() - } - }) - - it('classifies an abrupt body close as TRANSPORT', async () => { - const server = await mockServer([{ - kind: 'close-early', - events: ['{"choices":[{"delta":{"content":"par"}}]}'], - }]) - const ctx = await harness(server.url) - const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish.kind).toBe('error') - if (result.finish.kind !== 'error') throw new Error('expected an error finish') - expect(result.finish.failure.code).toBe('TRANSPORT') - expect(result.finish.failure.message).toMatch(/^DeepSeek API stream from .* failed$/) - }) - - it('aborts mid-stream via the request signal', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }]) - const ctx = await harness(server.url) - const controller = new AbortController() - - const pending = (async () => { - const chunks = [] - for await (const chunk of ctx.llm.stream({ - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - messages: [], - signal: controller.signal, - })) { - chunks.push(chunk) - } - return chunks - })() - - setTimeout(() => { controller.abort() }, 30) - const chunks = await pending - expect(chunks).toHaveLength(1) - expect(chunks[0]?.type).toBe('finish') - if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk') - expect(chunks[0].reason.kind).toBe('aborted') - if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish') - expect(chunks[0].reason.failure.code).toBe('ABORTED') - }) - - it('maps connection failures to TRANSPORT without losing the cause', async () => { - const cause = new TypeError('connection refused') - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) - const adapter = adapterOf({ baseURL: 'https://example.invalid' }) - try { - const drain = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause }) - } finally { - fetchSpy.mockRestore() - } - }) - - it('renders a non-Error transport rejection without losing its cause', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { - const failed = Promise.withResolvers() - failed.reject('offline') - return failed.promise - }) - const adapter = adapterOf({ baseURL: 'https://example.invalid' }) - try { - const drain = async (): Promise => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - } - await expect(drain()).rejects.toMatchObject({ - message: 'DeepSeek API request to https://example.invalid failed', - code: 'TRANSPORT', - cause: 'offline', - }) - } finally { - fetchSpy.mockRestore() - } - }) - - it('aborts the underlying body when the stream stays idle past its watchdog', async () => { - vi.useFakeTimers() - let stopped = false - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { - const signal = init?.signal - const body = new ReadableStream({ - start(controller) { - signal?.addEventListener('abort', () => { - stopped = true - controller.error(signal.reason) - }, { once: true }) - }, - }) - return Promise.resolve(new Response(body, { status: 200 })) - }) - const adapter = adapterOf({ - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) - try { - const drain = (async () => { - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - })() - const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' }) - await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(100) - await rejected - expect(stopped).toBe(true) - } finally { - fetchSpy.mockRestore() - } - }) - - it('keeps an idle provider read alive through SSE comments', async () => { - vi.useFakeTimers() - const encoder = new TextEncoder() - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { - const body = new ReadableStream({ - start(controller) { - setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) - setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) - setTimeout(() => { - controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) - controller.close() - }, 225) - }, - }) - return Promise.resolve(new Response(body, { status: 200 })) - }) - const adapter = adapterOf({ - baseURL: 'https://example.invalid', - streamIdleTimeoutMs: 100, - }) - try { - const chunks: string[] = [] - const drain = (async () => { - for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { - chunks.push(chunk.type) - } - })() - await vi.advanceTimersByTimeAsync(75) - await vi.advanceTimersByTimeAsync(75) - await vi.advanceTimersByTimeAsync(75) - await expect(drain).resolves.toBeUndefined() - expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) - } finally { - fetchSpy.mockRestore() - } + it('rejects a successful response with no readable body', async () => { + vi.stubGlobal('fetch', async () => new Response(null, { status: 200 })) + await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'EMPTY_RESPONSE' }) }) }) -describe('plugin registration and config', () => { - it('defaults to Messages and resolves the selected protocol endpoint without rewriting overrides', () => { - expect(resolveAdapterOptions({})).toMatchObject({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic' }) - expect(resolveAdapterOptions({ protocol: 'chat-completions' })).toMatchObject({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com' }) - expect(resolveAdapterOptions({ protocol: 'messages' })).toMatchObject({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic' }) - for (const baseURL of ['https://gateway.example/custom/v1', 'https://gateway.example/v1/messages']) { - expect(resolveAdapterOptions({ protocol: 'messages', baseURL }).baseURL).toBe(baseURL) - } - expect(() => resolveAdapterOptions({ protocol: 'responses' } as unknown as LlmDeepSeek.Config)).toThrow(/protocol/) - }) - - it('keeps wire helpers off the package root', () => { - for (const helper of [ - 'httpErrorCode', - 'serializeMessages', - 'serializeRequest', - 'DONE', - 'parseSse', - 'mapFinishReason', - 'mapUsage', - 'translate', - ]) expect(LlmDeepSeek).not.toHaveProperty(helper) - }) - - it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { - const server = await mockServer([]) - const ctx = new Context() +describe('Cordis provider composition', () => { + it('resolves an attachment service loaded after the adapter and maps its read-only path', async () => { + const http = await endpoint() + const { ctx, home } = await context() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') await ctx.plugin(LlmRuntime) - const fiber = await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: server.url, + await ctx.plugin(Messages, { baseURL: http.url }) + const model = 'deepseek-flash' + const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)! + const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const } + expect(price().priceImages([{ type: 'image', attachment: dummy }])[0]?.text).toBeDefined() + await ctx.plugin(LocalAttachments, { dshHome: home }) + const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) + expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).not.toContain('/mounted/image.png') + class MappedFiles extends Service { + constructor(context: Context) { super(context, 'fs') } + processPathFromHostPath(_path: string) { return '/mounted/image.png' } + } + await ctx.plugin(MappedFiles) + const message = user() + await chunks(ctx.llm.stream(options({ model, messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }] }))) + expect(JSON.stringify(http.requests[0]?.body)).toContain('/mounted/image.png') + expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).toContain('/mounted/image.png') + }) + + async function boot(...args: Parameters) { + const http = await endpoint(...args) + const { ctx, home } = await context() + vi.stubEnv('DEEPSEEK_API_KEY', '') + await writeFile(join(home, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: stored-key\n', { mode: 0o600 }) + await writeFile(join(home, 'settings.yaml'), '{}\n') + const template = await readFile(new URL('fixtures/cordis.yml', import.meta.url), 'utf8') + await writeFile(join(home, 'cordis.yml'), template.replaceAll('{{endpoint}}', JSON.stringify(http.url)).replaceAll('{{settings}}', JSON.stringify(join(home, 'settings.yaml'))).replaceAll('{{credentials}}', JSON.stringify(join(home, '.credentials.yaml')))) + ctx.baseUrl = pathToFileURL(home).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek', Messages], + ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-settings-file', FileSettings], + ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], + ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ]) + // The importer supplies source modules while Loader still owns configuration and effects. + for (const name of modules.keys()) { + const directory = join(home, 'node_modules', name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '0.1.3-alpha.1', type: 'module' })) + } + ctx.loader.internal = sourceModuleLoader(async (name) => { + if (!modules.has(name)) throw new Error(`unexpected module ${name}`) + return modules.get(name) }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - expect(ctx.llm.listConfigurableProviders()).toEqual([{ - provider: 'deepseek-official', - displayName: 'DeepSeek', - settingsNs: 'llm-deepseek', - settingsPath: [], - }]) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(home, 'cordis.yml')).href } }) + await ctx.loader.await() + return { ctx, http } + } + + it.each([ + { model: MODEL, inHistory: false }, + { model: MODEL, inHistory: true }, + { model: 'deepseek-flash', inHistory: true }, + ])('updates, clears and restores prompts across continued and resumed sessions, model=$model in-history=$inHistory', async ({ model, inHistory }) => { + const { ctx, http } = await boot() + if (inHistory && model === MODEL) await ctx.settings.update(Messages.name, { models: [{ id: model, systemPromptUpdate: 'in-history' }] }) + let prompt = 'first prompt' + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ + ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], + })) + const agentOptions = { provider: 'deepseek-official', model } + const agent = await ctx.agentLoop.create(SessionId('prompt-update'), agentOptions) + await send(agent, 'first') + prompt = 'second prompt' + await send(agent, 'second') + const count = agent.session.snapshotEvents().filter(event => event.type === 'system/message').length + await send(agent, 'unchanged') + expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) + prompt = '' + await send(agent, 'clear') + const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('prompt-resume'), agentOptions, + seed: [...agent.session.snapshotEvents()] }) + await send(resumed, 'resume cleared') + prompt = 'restored prompt' + await send(resumed, 'restore') + expect(http.requests.map(request => request.body.system)).toEqual(inHistory + ? ['first prompt', 'first prompt', 'first prompt', undefined, undefined, undefined] + : ['first prompt', 'second prompt', 'second prompt', undefined, undefined, 'restored prompt']) + for (const [index, request] of http.requests.entries()) { + const messages = request.body.messages as { role: string; content: unknown[] }[] + expect(messages.filter(message => message.role === 'assistant')).toHaveLength(index) + expect(messages.filter(message => message.role === 'system').map(message => message.content)).toEqual( + !inHistory ? [] : index === 1 || index === 2 ? [[{ type: 'text', text: 'second prompt' }]] + : index === 5 ? [[{ type: 'text', text: 'restored prompt' }]] : [], + ) + expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/first prompt|second prompt|restored prompt/) + } + expect(resumed.session.requestContext()?.systemPromptUpdate).toBe(inHistory ? 'in-history' : undefined) + }) + + it.each([false, true])('continues and resumes sessions after a model capability change, in-history=%s', async (inHistory) => { + const { ctx, http } = await boot() + await ctx.settings.update('llm-deepseek', { baseURL: http.url, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) + let prompt = 'old prompt' + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ + ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], + })) + const selection: ModelSelectionRef = { current: { provider: 'deepseek-official', model: MODEL }, assembled: undefined } + const agent = await ctx.agentLoop.create(SessionId('capability-switch'), selection.current) + installModelSelection(agent.ctx, selection) + await send(agent, 'first') + prompt = 'current prompt' + await send(agent, 'second') + expect((http.requests[1]?.body.messages as { role: string }[]).filter(message => message.role === 'system')).toHaveLength(1) + const seed = [...agent.session.snapshotEvents()] + const saved = JSON.stringify(seed) + await ctx.settings.update(Messages.name, { models: [{ id: MODEL, ...inHistory ? { systemPromptUpdate: 'in-history' } : {} }] }) + selection.current = { provider: 'deepseek-official', model: MODEL } + await send(agent, 'switch') + const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('switch-resume'), agentOptions: selection.current, seed }) + await send(resumed, 'resume') + for (const request of http.requests.slice(2)) { + expect(request.path).toBe('/anthropic/v1/messages') + expect(request.body.system).toBe(inHistory ? 'old prompt' : 'current prompt') + const messages = request.body.messages as { role: string }[] + expect(messages.filter(message => message.role === 'assistant')).toHaveLength(2) + expect(messages.filter(message => message.role === 'system')).toHaveLength(inHistory ? 1 : 0) + expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/old prompt|current prompt/) + } + expect(JSON.stringify(seed)).toBe(saved) + expect(agent.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) + expect(resumed.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) + }) + + it('maps multiple system snapshots on direct compaction calls to the latest prompt', async () => { + const { ctx, http } = await boot() + const history = [createSystemMessage('old'), user(), + createAssistantMessage({ content: [{ type: 'text', text: 'OK' }], source: { provider: 'deepseek-official', model: MODEL } }), + createSystemMessage('current'), user('summarize')] + const saved = JSON.stringify(history) + const response = await assemble(ctx.llm.stream(options({ messages: history, purpose: 'compaction' }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.body.system).toBe('current') + expect((http.requests[0]?.body.messages as { role: string }[]).map(message => message.role)).toEqual(['user', 'assistant', 'user']) + expect(JSON.stringify(history)).toBe(saved) + }) + + it('continues a recorded tool turn with a warning when its native replay version is unknown', async () => { + const { ctx, http } = await boot() + const warnings: unknown[][] = [] + ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) + const fixture = await readFile(new URL('../../../../snapshots/session/deepseek-messages-degraded-replay/session.v2.jsonl', import.meta.url), 'utf8') + const records = fixture.trim().split('\n').map(line => JSON.parse(line) as { type: string; data: { message?: Message } }) + const assistant = records.find(record => record.type === 'assistant/message')!.data.message! + if (assistant.source.kind === 'model') assistant.source.provider = 'deepseek-official' + // The released v2 row still wraps its tool result inside a user message. + const released = object(records.find(record => record.type === 'tool/result')!.data.message) + if (!Array.isArray(released.content)) throw new Error('fixture lacks released tool content') + const releasedBlock = object(released.content[0]) + if (typeof releasedBlock.toolCallId !== 'string' || !Array.isArray(releasedBlock.content)) { + throw new Error('fixture lacks released tool result') + } + const content = releasedBlock.content.map((value) => { + const block = object(value) + if (block.type !== 'text' || typeof block.text !== 'string') throw new Error('fixture tool result must contain text') + return { type: 'text' as const, text: block.text } + }) + const result = createToolResultMessage({ + callId: ToolCallId(releasedBlock.toolCallId), content, isError: releasedBlock.isError === true, + }) + const saved = JSON.stringify([assistant, result]) + const response = await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] }))) + expect(response.assembler.finish.kind).toBe('stop') + expect(warnings).toEqual([[`llm-deepseek: unusable Messages replay state on assistant history for route "deepseek-official/${MODEL}"; sending provider-neutral content (DeepSeek Messages replay: unsupported kind or version)`]]) + expect(http.requests).toHaveLength(1) + expect(http.requests[0]?.body.messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [ + { type: 'thinking', thinking: 'The user wants me to run a simple bash command and then reply with "DONE".' }, + { type: 'tool_use', id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', name: 'bash', input: { command: 'echo TERMINAL_OK', description: 'Echo TERMINAL_OK to verify terminal access' } }, + ] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', content: [{ type: 'text', text: 'TERMINAL_OK\n' }], is_error: false }] }, + ]) + expect(JSON.stringify([assistant, result])).toBe(saved) + }) + + it('loads one provider from YAML, rotates settings and credentials, then removes disposed registrations', async () => { + const { ctx, http } = await boot() + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek-official']) + expect((await assemble(ctx.llm.stream(options()))).assembler.finish.kind).toBe('stop') + expect(http.requests[0]?.headers['x-api-key']).toBe('stored-key') + const second = await endpoint() + await ctx.settings.update(Messages.name, { baseURL: second.url, maxTokens: 51, retryPolicy: { mode: 'always' } }) + await ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'rotated') + await chunks(ctx.llm.stream(options())) + expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'rotated' }, body: { max_tokens: 51 } }) + await ctx.settings.update(Messages.name, { models: [{ id: 'duplicate' }, { id: 'duplicate' }], baseURL: http.url }) + await chunks(ctx.llm.stream(options())) + expect(second.requests).toHaveLength(2) + expect(http.requests).toHaveLength(1) + await ctx.settings.update(Messages.name, { models: [{ id: MODEL }], baseURL: http.url }) + await chunks(ctx.llm.stream(options())) + expect(http.requests).toHaveLength(2) + const llm = ctx.llm + await ctx.fiber.dispose() + expect(llm.listProviders()).toEqual([]) + expect(llm.listConfigurableProviders()).toEqual([]) + }) + + it('uses environment credentials and reports missing or malformed keys without network access', async () => { + const http = await endpoint() + const { ctx } = await context() + vi.stubEnv('DEEPSEEK_BASE_URL', http.url) + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + await ctx.plugin(LlmRuntime) + const fiber = ctx.plugin(Messages) + await fiber + await chunks(ctx.llm.stream(options())) + expect(http.requests[0]?.headers['x-api-key']).toBe('env-key') + vi.stubEnv('DEEPSEEK_API_KEY', '') + expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + vi.stubEnv('DEEPSEEK_API_KEY', 'bad\nkey') + expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'INVALID_CREDENTIAL' } }) await fiber.dispose() expect(ctx.llm.listProviders()).toEqual([]) - expect(ctx.llm.listConfigurableProviders()).toEqual([]) - }) - - it('registers retryPolicy from the provider config', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - retryPolicy: { - mode: 'always', - backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, - }, - }) - - expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({ - mode: 'always', - initialDelayMs: 25, - maxDelayMs: 100, - jitterRatio: 0.2, - }) - }) - - it('owns the deepseek provider and advertises the default models', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, - { - provider: 'deepseek-official', - id: 'deepseek-v4-pro', - name: 'DeepSeek-V4-Pro', - description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', - inputModalities: ['text'], - }, - ]) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')) - .resolves.toMatchObject({ - provider: 'deepseek-official', - id: 'deepseek-flash', - name: 'DeepSeek-V41-Flash', - inputModalities: ['text', 'image'], - systemPromptUpdate: 'in-history', - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, - { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, - { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, - { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, - ], - defaultEffort: ReasoningEffortId('high'), - }, - }) - }) - - it('keeps deepseek-v4-pro available with its V4 capabilities', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - const info = await ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro') - expect(info).toMatchObject({ - id: 'deepseek-v4-pro', - inputModalities: ['text'], - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - }) - expect(info?.systemPromptUpdate).toBeUndefined() - }) - - it.each(['off', 'low', 'max'] as const)('uses the configured %s reasoning default', async (effort) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - reasoningEffort: effort, - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ - reasoning: { - efforts: [ - { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, - { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, - { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, - { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, - ], - defaultEffort: ReasoningEffortId(effort), - }, - }) - }) - - it('accepts off as the default when thinking is deployment-disabled', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - thinking: 'disabled', - reasoningEffort: 'off', - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it.each(['low', 'high', 'max'] as const)( - 'rejects configured reasoning effort %s when thinking is disabled', - async (reasoningEffort) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - thinking: 'disabled', - reasoningEffort, - })).rejects.toThrow(/only reasoningEffort "off"/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each(['low', 'high', 'max'] as const)( - 'rejects disabled-thinking effort %s at the resolver boundary', - (reasoningEffort) => { - expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) - .toThrow(/only reasoningEffort "off"/) - }, - ) - - it('accepts disabled thinking with off at the resolver boundary', async () => { - const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) - await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({ - reasoning: { - efforts: [{ - id: ReasoningEffortId('off'), - name: 'Off', - description: 'Use for simple tasks that do not need reasoning.', - }], - defaultEffort: ReasoningEffortId('off'), - }, - }) - }) - - it('uses the default model catalog when apply is called directly', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, - { - provider: 'deepseek-official', - id: 'deepseek-v4-pro', - name: 'DeepSeek-V4-Pro', - description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', - inputModalities: ['text'], - }, - ]) - }) - - it('defaults an adapter-supplied catalog entry to text input', async () => { - const connection = resolveAdapterOptions({ models: [] }) - const adapter = new DeepSeekAdapter({ - options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), - resolveApiKey: () => Promise.resolve('k'), - resolveUserId: () => TEST_USER_ID, - prepareExtensions: noExtensions, - }) - await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ - provider: 'deepseek-official', - id: 'adapter-model', - name: 'adapter-model', - inputModalities: ['text'], - }]) - }) - - it('advertises configured models without restricting arbitrary request ids', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [ - { id: 'private-fast', contextWindow: 32_000 }, - { - id: 'private-reasoner', - name: 'Private Reasoner', - description: 'Higher reasoning budget', - contextWindow: 64_000, - inputModalities: ['text', 'image'], - }, - ], - }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, - ]) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) - .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-reasoner')) - .resolves.toMatchObject({ - name: 'Private Reasoner', - description: 'Higher reasoning budget', - inputModalities: ['text', 'image'], - }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) - .resolves.toMatchObject({ - context: { contextWindow: 1_000_000 }, - defaultMaxTokens: 256_000, - }) - }) - - it('uses exact model capacity before the adapter-wide default', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow: 256_000, - models: [ - { id: 'inherits-default' }, - { id: 'exact-override', contextWindow: 64_000 }, - ], - }) - - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'inherits-default')) - .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'exact-override')) - .resolves.toMatchObject({ context: { contextWindow: 64_000 } }) - await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) - .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) - }) - - it('allows an explicit empty model catalog', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [], - }) - await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) - }) - - const invalidModels: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ - [[{ id: '' }], /ids must be non-empty/], - [[{ id: 'm', name: '' }], /empty name/], - [[{ id: 'm', contextWindow: 0 }], /contextWindow/], - [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], - [[{ id: 'm', inputModalities: [] }], /inputModalities/], - [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], - [[{ - id: 'm', - inputModalities: ['audio'] as unknown as NonNullable, - }], /expected "text" \| "image"/], - [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], - ] - - it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [...models], - })).rejects.toThrow(message) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - const invalidProgrammaticModalities: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ - [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], - [[{ - id: 'm', - inputModalities: ['audio'] as unknown as NonNullable, - }], /inputModalities must contain only "text" and "image"/], - ] - - it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { - expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) - }) - - it('rejects the removed imageDetail model setting through schema and direct construction', async () => { - const legacyModel = { id: 'vision', inputModalities: ['image'], imageDetail: 'low' } as unknown as - LlmDeepSeek.DeepSeekCatalogModel - expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - models: [legacyModel], - })).rejects.toThrow(/imageDetail is no longer supported/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { - expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) - .toThrow(/maxTokens must be a positive integer/) - }) - - it('surfaces a catalog model\'s in-history system prompt update mode and rejects any other mode', async () => { - const adapter = adapterOf({ models: [ - { id: 'capable', systemPromptUpdate: 'in-history' }, - { id: 'plain' }, - ] }) - await expect(adapter.resolveModel('deepseek-official', 'capable')) - .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) - await expect(adapter.resolveModel('deepseek-official', 'plain')) - .resolves.not.toHaveProperty('systemPromptUpdate') - await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) - .resolves.not.toHaveProperty('systemPromptUpdate') - expect(() => resolveAdapterOptions({ - models: [{ id: 'bogus', systemPromptUpdate: 'leading' as unknown as 'in-history' }], - })).toThrow(/systemPromptUpdate must be "in-history" when present/) - }) - - it('rejects image request limits on a text-only catalog model', () => { - expect(() => resolveAdapterOptions({ - models: [{ id: 'text-only', inputModalities: ['text'], imagePixelBudget: 1 }], - })).toThrow(/text-only catalog model .* cannot declare image request limits/) - }) - - it.each([ - ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], - ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], - ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], - ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], - ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], - ] as const)('rejects per-model %s=%s', (field, value, message) => { - expect(() => resolveAdapterOptions({ - models: [{ id: 'vision', inputModalities: ['image'], [field]: value }], - })).toThrow(message) - }) - - it('prefers a model\'s own output cap over the profile default', async () => { - // The profile default stays what an unlisted or uncapped model resolves - // to, so adding a per-model cap changes one model rather than the route. - const adapter = adapterOf({ maxTokens: 4096, models: [ - { id: 'capped', maxTokens: 512 }, - { id: 'uncapped' }, - ] }) - await expect(adapter.resolveModel('deepseek-official', 'capped')) - .resolves.toMatchObject({ defaultMaxTokens: 512 }) - await expect(adapter.resolveModel('deepseek-official', 'uncapped')) - .resolves.toMatchObject({ defaultMaxTokens: 4096 }) - await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) - .resolves.toMatchObject({ defaultMaxTokens: 4096 }) - }) - - it('rejects invalid context capacity when apply is called directly', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - expect(() => { - LlmDeepSeek.apply(ctx, { - baseURL: 'http://127.0.0.1:1', - models: [{ id: 'invalid-context', contextWindow: 0 }], - }) - }).toThrow(/contextWindow must be a positive integer/) - expect(ctx.llm.listProviders()).toEqual([]) - }) - - it.each([0, 1.5])( - 'rejects invalid adapter-wide default context capacity %s', - async (defaultContextWindow) => { - expect(() => resolveAdapterOptions({ defaultContextWindow })) - .toThrow(/defaultContextWindow must be a positive integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - defaultContextWindow, - })).rejects.toThrow(/defaultContextWindow/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid adapter-wide maxTokens %s', - async (maxTokens) => { - expect(() => resolveAdapterOptions({ maxTokens })) - .toThrow(/maxTokens must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxTokens, - })).rejects.toThrow(/maxTokens/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it('rejects offload quanta larger than their request bounds', () => { - expect(() => resolveAdapterOptions({ - maxRequestFilesBytes: 10, - imageOffloadByteQuantum: 11, - })).toThrow(/imageOffloadByteQuantum must not exceed maxRequestFilesBytes/) - expect(() => resolveAdapterOptions({ - maxInlineRequestImageBytes: 10, - inlineImageOffloadByteQuantum: 11, - })).toThrow(/inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes/) - expect(() => resolveAdapterOptions({ - maxImagesPerRequest: 10, - imageOffloadCountQuantum: 11, - })).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/) - }) - - it.each([ - ['maxImagesPerRequest', 0, /maxImagesPerRequest must be a positive safe integer/], - ['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/], - ['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/], - ['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/], - ['inlineImageOffloadByteQuantum', 0, /inlineImageOffloadByteQuantum must be a positive safe integer/], - ['inlineImageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /inlineImageOffloadByteQuantum must be a positive safe integer/], - ['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/], - ['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/], - ['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], - ['fileExpiresAfterSeconds', 2_592_001, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], - ['fileRefreshMarginSeconds', -1, /fileRefreshMarginSeconds must be a non-negative integer/], - ['fileRefreshMarginSeconds', 604_800, /fileRefreshMarginSeconds must be a non-negative integer/], - ['fileQuotaCleanupBatch', 0, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], - ['fileQuotaCleanupBatch', 1_001, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], - ] as const)('rejects %s=%s', (field, value, message) => { - expect(() => resolveAdapterOptions({ [field]: value })).toThrow(message) - }) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid request file bound %s', - async (maxRequestFilesBytes) => { - expect(() => resolveAdapterOptions({ maxRequestFilesBytes })) - .toThrow(/maxRequestFilesBytes must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxRequestFilesBytes, - })).rejects.toThrow(/maxRequestFilesBytes/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( - 'rejects invalid inline request image bound %s', - async (maxInlineRequestImageBytes) => { - expect(() => resolveAdapterOptions({ maxInlineRequestImageBytes })) - .toThrow(/maxInlineRequestImageBytes must be a positive safe integer/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - maxInlineRequestImageBytes, - })).rejects.toThrow(/maxInlineRequestImageBytes/) - expect(ctx.llm.listProviders()).toEqual([]) - }, - ) - - it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - }) - - it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - // 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) - const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) - expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - // The guidance leads with the managed credential store. - const second = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) - expect(second.finish.kind).toBe('error') - if (second.finish.kind !== 'error') throw new Error('expected an error finish') - // The guidance names both places a credential can come from, and nothing - // else: configuration carries the reference, never a literal key. - expect(second.finish.failure.message) - .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s) - }) - - it('reads the ambient variable when no credentials seam is mounted', async () => { - // The plain cordis.yml composition: no credential provider, the key in - // the launching environment. - vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: server.url }) - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer ambient-key') - }) - - it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', '') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://127.0.0.1:1' }) - const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - }) - - it('prefers explicit config over env for key and base URL', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url) // harness passes explicit config - await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(server.requests).toHaveLength(1) // hit the explicit URL, not env - }) - - it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - vi.stubEnv('DEEPSEEK_BASE_URL', server.url) - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - expect(server.requests).toHaveLength(1) - }) - - - it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => { - const trusted = createLaunchEnvironmentSnapshot([ - { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, - ]) - expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') - // The product trusts the project it is launched in, so a checkout can - // point its own agent at the gateway that checkout is meant to use. - const project = createLaunchEnvironmentSnapshot([ - { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } }, - ]) - expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example') - // An explicitly configured endpoint outranks every environment layer, so a - // stale shell value cannot rewrite a deployment's own gateway. - const shell = createLaunchEnvironmentSnapshot([ - { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, - ]) - expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') - }) - it('uses the public Chat base URL when selected without an endpoint override', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'k') - vi.stubEnv('DEEPSEEK_BASE_URL', undefined) - const ctx = new Context() - await ctx.plugin(LlmRuntime) - // Registration succeeds; no call is made (would hit api.deepseek.com). - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions' }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) - }) - - it('adapter is constructible directly for embedding over the shared resolver', async () => { - const adapter = adapterOf() - 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) - }) - - it('resolves connection facts and the credential exactly once per stream call', async () => { - const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const options = vi.fn(() => resolveAdapterOptions({ protocol: 'chat-completions', baseURL: server.url })) - const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) - const resolveUserId = vi.fn(() => TEST_USER_ID) - const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId, prepareExtensions: noExtensions }) - - for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } - - expect(options).toHaveBeenCalledTimes(1) - expect(resolveApiKey).toHaveBeenCalledTimes(1) - expect(resolveUserId).toHaveBeenCalledTimes(1) - expect(server.headers[0]?.authorization).toBe('Bearer per-request-key') - }) - - it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { - expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) - .toThrow(/streamIdleTimeoutMs.*positive finite/) - expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) - .toThrow(/streamIdleTimeoutMs.*no greater/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: 0, - })).rejects.toThrow(/streamIdleTimeoutMs/) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).rejects.toThrow(/streamIdleTimeoutMs/) - }) - - it('validates Files API timeout bounds independently of the stream idle deadline', async () => { - expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) - .toThrow(/filesApiTimeoutMs.*positive finite/) - expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) - .toThrow(/filesApiTimeoutMs.*no greater/) - - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - filesApiTimeoutMs: 0, - })).rejects.toThrow(/filesApiTimeoutMs/) - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, - })).rejects.toThrow(/filesApiTimeoutMs/) - expect(resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) - .toMatchObject({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 }) - }) - - it('rejects invalid nested retryPolicy before registering the provider', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - - await expect(ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', - baseURL: 'http://127.0.0.1:1', - retryPolicy: { mode: 'normal', maxRetries: -1 }, - })).rejects.toThrow(/retryPolicy/) - expect(ctx.llm.listProviders()).toEqual([]) }) }) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 7288bb9bfb..9872b90f89 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -131,7 +131,7 @@ async function boot(dir: string, config: object): Promise { const settingsFiber = ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', ...config }) + await ctx.plugin(LlmDeepSeek, { ...config }) return { ctx, settingsFiber } } @@ -149,7 +149,7 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: serverA.url }) await prompt(ctx) - expect(serverA.headers[0]?.authorization).toBe('Bearer first-key') + expect(serverA.headers[0]?.['x-api-key']).toBe('first-key') await ctx.settings.update(NS, { baseURL: serverB.url }) await ctx.credentials.set(KEY_REF, 'second-key') @@ -157,7 +157,7 @@ describe('request-level dynamic configuration', () => { await prompt(ctx) // No restart, no re-registration: the next request resolved both facts. expect(serverA.requests).toHaveLength(1) - expect(serverB.headers[0]?.authorization).toBe('Bearer second-key') + expect(serverB.headers[0]?.['x-api-key']).toBe('second-key') }) it('starts keyless and serves the next request once the key arrives', async () => { @@ -171,7 +171,7 @@ describe('request-level dynamic configuration', () => { await expect(access(join(dir, '.anonymous-user-id'))).rejects.toMatchObject({ code: 'ENOENT' }) await ctx.credentials.set(KEY_REF, 'sk-arrived') await prompt(ctx) - expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') + expect(server.headers[0]?.['x-api-key']).toBe('sk-arrived') await expect(access(join(dir, '.anonymous-user-id'))).resolves.toBeUndefined() }) @@ -246,10 +246,10 @@ describe('request-level dynamic configuration', () => { const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content expect(server.requests).toHaveLength(2) - expect(JSON.stringify(first).match(/"type":"file"/g)).toHaveLength(2) + expect(JSON.stringify(first).match(/"type":"image"/g)).toHaveLength(2) expect(JSON.stringify(second)).toContain('[image omitted to fit request image limits') expect(JSON.stringify(second)).toContain(MODEL_IMAGE_PATH) - expect(JSON.stringify(second).match(/"type":"file"/g)).toHaveLength(1) + expect(JSON.stringify(second).match(/"type":"image"/g)).toHaveLength(1) }) it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() @@ -309,7 +309,29 @@ describe('request-level dynamic configuration', () => { // regression this pins — not its key either. expect(rejected.requests).toHaveLength(0) expect(good.requests).toHaveLength(1) - expect(good.headers[0]?.authorization).toBe('Bearer good-key') + expect(good.headers[0]?.['x-api-key']).toBe('good-key') + }) + + it.each(['messages', 'chat-completions'])('rejects a persisted protocol=%s section and recovers after removing the field', async (protocol) => { + const dir = await home() + const composition = await mockServer([{ kind: 'sse', events: textEvents }]) + const repaired = await mockServer([{ kind: 'sse', events: textEvents }]) + vi.stubEnv('DEEPSEEK_API_KEY', 'composition-key') + vi.stubEnv('REPAIRED_DEEPSEEK_KEY', 'repaired-key') + const section = { baseURL: repaired.url, apiKeyEnv: 'REPAIRED_DEEPSEEK_KEY' } + await writeFile(join(dir, 'settings.yaml'), JSON.stringify({ [NS]: { ...section, protocol } })) + const { ctx } = await boot(dir, { baseURL: composition.url }) + + await prompt(ctx) + expect(composition.requests).toHaveLength(1) + expect(composition.headers[0]?.['x-api-key']).toBe('composition-key') + expect(repaired.requests).toHaveLength(0) + + await ctx.settings.replace(NS, section) + await prompt(ctx) + expect(repaired.requests).toHaveLength(1) + expect(repaired.headers[0]?.['x-api-key']).toBe('repaired-key') + expect(ctx.settings.get(NS)).not.toHaveProperty('protocol') }) it('falls back to the composition entry when settings detach', async () => { @@ -327,6 +349,6 @@ describe('request-level dynamic configuration', () => { await settingsFiber.dispose() await prompt(ctx) expect(serverA.requests).toHaveLength(1) - expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key') + expect(serverA.headers[0]?.['x-api-key']).toBe('steady-key') }) }) diff --git a/packages/llm/llm-deepseek/tests/egress.spec.ts b/packages/llm/llm-deepseek/tests/egress.spec.ts index 61629ef425..3a74a50cb8 100644 --- a/packages/llm/llm-deepseek/tests/egress.spec.ts +++ b/packages/llm/llm-deepseek/tests/egress.spec.ts @@ -51,19 +51,19 @@ afterAll(() => { rmSync(home, { recursive: true, force: true }) }) -/** Drive the shipping adapter's chat-completions request at an unresolvable endpoint. */ +/** Drive the shipping adapter's Messages request at an unresolvable endpoint. */ async function streamOnce(): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(LlmDeepSeek, { protocol: 'chat-completions', baseURL: 'http://deepseek-probe.invalid/v1', models: [{ id: 'm' }] }) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://deepseek-probe.invalid/v1', models: [{ id: 'm' }] }) for await (const _chunk of ctx.llm.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { // The endpoint never answers; the proxy record is the assertion. } } describe('llm-deepseek egress', () => { - it('sends the chat-completions request through the proxy', async () => { + it('sends the Messages request through the proxy', async () => { const observed = await observe(streamOnce) expect(observed.join('|')).toContain('deepseek-probe.invalid') }) diff --git a/packages/llm/llm-deepseek/tests/messages/expected/degraded-replay.json b/packages/llm/llm-deepseek/tests/expected/degraded-replay.json similarity index 100% rename from packages/llm/llm-deepseek/tests/messages/expected/degraded-replay.json rename to packages/llm/llm-deepseek/tests/expected/degraded-replay.json diff --git a/packages/llm/llm-deepseek/tests/messages/extensions.spec.ts b/packages/llm/llm-deepseek/tests/extensions.spec.ts similarity index 99% rename from packages/llm/llm-deepseek/tests/messages/extensions.spec.ts rename to packages/llm/llm-deepseek/tests/extensions.spec.ts index 29c2ce10c0..66256ce5df 100644 --- a/packages/llm/llm-deepseek/tests/messages/extensions.spec.ts +++ b/packages/llm/llm-deepseek/tests/extensions.spec.ts @@ -8,7 +8,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import type { DeepSeekLlmApiExtensionRequest } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { SessionId } from '@deepseek-ai/dsh-session' -import * as DeepSeek from '../../src/index.ts' +import * as DeepSeek from '../src/index.ts' import { assemble, options, sse, textEvents } from './helpers.ts' declare module '@deepseek-ai/dsh-deepseek-llm-api-extensions' { diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index 10af67203a..a671607788 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -4,9 +4,9 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { DeepSeekFileStore, MAX_IMAGE_BYTES } from '../src/common/file-store.ts' -import { DeepSeekFileId } from '../src/common/file-id.ts' -import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/common/upload-index.ts' +import { DeepSeekFileStore, MAX_IMAGE_BYTES } from '../src/file-store.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' const REF: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), @@ -27,7 +27,7 @@ const VERSION: RequestImageAttachment = { space: 'srgb', hasAlpha: true, } -const CONNECTION = { protocol: 'chat-completions' as const, baseURL: 'https://api.deepseek.com', apiKey: 'key' } +const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' } const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 } const NOW = 1_700_000_000_000 @@ -50,17 +50,16 @@ function uploadFetch(now: () => number = () => NOW) { const createdAt = now() / 1_000 return new Response(JSON.stringify({ id: `file-api-${uploads}`, - object: 'file', - bytes: 3, - created_at: createdAt, + type: 'file', + size_bytes: 3, + created_at: new Date(createdAt * 1_000).toISOString(), filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, - purpose: 'user_data', - expires_at: createdAt + POLICY.expiresAfterSeconds, + mime_type: 'image/png', }), { status: 200 }) } if (init?.method === 'DELETE') { const id = requestUrl(_url).split('/').at(-1) - return new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 }) + return new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 }) } throw new Error('unexpected Files API request') }) as typeof fetch @@ -68,42 +67,34 @@ function uploadFetch(now: () => number = () => NOW) { } describe('DeepSeekFileStore', () => { - it('separates native Files reuse, invalidation, and expiry from the chat namespace', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dsh-native-file-store-')) + it('reuses equivalent API roots and isolates other endpoints across invalidation and expiry', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-file-scope-')) roots.push(dir) let now = NOW - let uploads = 0 - const fetchImpl: typeof fetch = async (input, init) => { - if (init?.method !== 'POST') throw new Error('only uploads expected') - uploads++ - const common = { id: `file-${uploads}`, filename: 'dsh-image.png' } - return new Response(JSON.stringify(requestUrl(input).includes('/v1/files') - ? { ...common, type: 'file', size_bytes: VERSION.bytes, created_at: new Date(now).toISOString(), mime_type: VERSION.mediaType } - : { ...common, object: 'file', bytes: VERSION.bytes, created_at: now / 1_000, expires_at: now / 1_000 + POLICY.expiresAfterSeconds, purpose: 'user_data' })) - } + const remote = uploadFetch(() => now) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) - const store = new DeepSeekFileStore({ index, fetch: fetchImpl, now: () => now }) - const native = { ...CONNECTION, protocol: 'messages' as const } - const chat = await store.ensureUploaded(VERSION, CONNECTION, POLICY) - const first = await store.ensureUploaded(VERSION, native, POLICY) + const store = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now }) + const alternate = { ...CONNECTION, baseURL: 'https://other.example' } + const other = await store.ensureUploaded(VERSION, alternate, POLICY) + const first = await store.ensureUploaded(VERSION, CONNECTION, POLICY) expect(first.record.scope).toBe(deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey)) - expect(first.record.scope).not.toBe(chat.record.scope) - expect((await store.ensureUploaded(VERSION, { ...native, baseURL: `${native.baseURL}/v1/` }, POLICY)).record).toEqual(first.record) - const reopened = new DeepSeekFileStore({ index, fetch: fetchImpl, now: () => now }) - expect((await reopened.ensureUploaded(VERSION, native, POLICY)).record).toEqual(first.record) - await reopened.invalidate(VERSION, chat.record.fileId, native) - expect((await reopened.ensureUploaded(VERSION, native, POLICY)).record).toEqual(first.record) - expect(uploads).toBe(2) - await reopened.invalidate(VERSION, first.record.fileId, native) - const replacement = await reopened.ensureUploaded(VERSION, native, POLICY) + expect(first.record.scope).not.toBe(other.record.scope) + expect((await store.ensureUploaded(VERSION, { ...CONNECTION, baseURL: `${CONNECTION.baseURL}/v1/` }, POLICY)).record).toEqual(first.record) + const reopened = new DeepSeekFileStore({ index, fetch: remote.fetchImpl, now: () => now }) + expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record) + await reopened.invalidate(VERSION, other.record.fileId, CONNECTION) + expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(first.record) + expect(remote.uploads()).toBe(2) + await reopened.invalidate(VERSION, first.record.fileId, CONNECTION) + const replacement = await reopened.ensureUploaded(VERSION, CONNECTION, POLICY) expect(replacement.record.fileId).not.toBe(first.record.fileId) - expect((await reopened.ensureUploaded(VERSION, CONNECTION, POLICY)).record).toEqual(chat.record) - expect(uploads).toBe(3) + expect((await reopened.ensureUploaded(VERSION, alternate, POLICY)).record).toEqual(other.record) + expect(remote.uploads()).toBe(3) now = replacement.record.expiresAt - POLICY.refreshMarginSeconds * 1_000 - const refreshed = await reopened.ensureUploaded(VERSION, native, POLICY) + const refreshed = await reopened.ensureUploaded(VERSION, CONNECTION, POLICY) expect(refreshed.record.fileId).not.toBe(replacement.record.fileId) expect(refreshed.record.expiresAt).toBe(now + POLICY.expiresAfterSeconds * 1_000) - expect(uploads).toBe(4) + expect(remote.uploads()).toBe(4) }) it('reclaims the oldest owned native file across descending pages before retrying an upload', async () => { @@ -135,7 +126,7 @@ describe('DeepSeekFileStore', () => { : { data: [file('oldest-owned', 3_000), file('foreign', 4_000, 'user-file.png')], has_more: false })) } const store = new DeepSeekFileStore({ index: new DeepSeekUploadIndex(join(dir, 'index.json')), fetch: fetchImpl, now: () => NOW }) - await expect(store.ensureUploaded(VERSION, { ...CONNECTION, protocol: 'messages' }, { ...POLICY, quotaCleanupBatch: 1 })).resolves.toMatchObject({ record: { fileId: 'file-uploaded' } }) + await expect(store.ensureUploaded(VERSION, CONNECTION, { ...POLICY, quotaCleanupBatch: 1 })).resolves.toMatchObject({ record: { fileId: 'file-uploaded' } }) expect(cursors).toEqual([null, 'middle']) expect(deleted).toEqual(['oldest-owned']) expect(uploads).toBe(2) @@ -192,12 +183,11 @@ describe('DeepSeekFileStore', () => { expect(uploadSignal?.aborted).toBe(false) complete?.(new Response(JSON.stringify({ id: 'file-api-shared', - object: 'file', - bytes: 3, - created_at: NOW / 1_000, + type: 'file', + size_bytes: 3, + created_at: new Date(NOW).toISOString(), filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, - purpose: 'user_data', - expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + mime_type: 'image/png', }), { status: 200 })) await expect(completed).resolves.toMatchObject({ record: { fileId: 'file-api-shared' } }) }) @@ -273,9 +263,8 @@ describe('DeepSeekFileStore', () => { }) } return Promise.resolve(new Response(JSON.stringify({ - id: 'file-api-retry', object: 'file', bytes: 3, created_at: NOW / 1_000, - filename: 'dsh-retry.png', purpose: 'user_data', - expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + id: 'file-api-retry', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(), + filename: 'dsh-retry.png', mime_type: 'image/png', }), { status: 200 })) }) as typeof fetch const store = new DeepSeekFileStore({ @@ -295,11 +284,11 @@ describe('DeepSeekFileStore', () => { await expect(retried).resolves.toMatchObject({ record: { fileId: 'file-api-retry' } }) }) - it.each(['chat-completions', 'messages'] as const)('rejects a request version above the %s per-image limit before transport', async (protocol) => { + it('rejects a request version above the per-image limit before transport', async () => { const fetchImpl = vi.fn() as typeof fetch const store = new DeepSeekFileStore({ now: () => NOW, fetch: fetchImpl }) const oversized = { ...VERSION, bytes: MAX_IMAGE_BYTES + 1 } - await expect(store.ensureUploaded(oversized, { ...CONNECTION, protocol }, POLICY)) + await expect(store.ensureUploaded(oversized, CONNECTION, POLICY)) .rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'DeepSeek image exceeds the 32 MiB per-image limit.' }) expect(fetchImpl).not.toHaveBeenCalled() }) @@ -329,9 +318,8 @@ describe('DeepSeekFileStore', () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) roots.push(dir) const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ - id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000, - filename: 'dsh-wrong.png', purpose: 'user_data', - expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + id: 'file-api-wrong-size', type: 'file', size_bytes: 2, created_at: new Date(NOW).toISOString(), + filename: 'dsh-wrong.png', mime_type: 'image/png', }), { status: 200 }))) as typeof fetch const store = new DeepSeekFileStore({ index: new DeepSeekUploadIndex(join(dir, 'index.json')), @@ -377,7 +365,7 @@ describe('DeepSeekFileStore', () => { }) }) - it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => { + it('reuses the local expiry above the refresh margin and uploads again at the margin', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) @@ -418,7 +406,7 @@ describe('DeepSeekFileStore', () => { vi.spyOn(index, 'commit').mockResolvedValue({ accepted: false, record: { - scope: deepSeekFileScope(CONNECTION.baseURL, CONNECTION.apiKey), + scope: deepSeekFileScope(`${CONNECTION.baseURL}/v1`, CONNECTION.apiKey), attachmentId: VERSION.attachment.attachmentId, variantId: VERSION.variantId, fileId: DeepSeekFileId('file-api-winner'), @@ -452,22 +440,20 @@ describe('DeepSeekFileStore', () => { error: { message: 'stored file quota exceeded', code: 'file_quota' }, }), { status: 400 })) return Promise.resolve(new Response(JSON.stringify({ - id: 'file-api-recovered', object: 'file', bytes: 3, created_at: NOW / 1_000, - filename: 'dsh-recovered.png', purpose: 'user_data', - expires_at: NOW / 1_000 + POLICY.expiresAfterSeconds, + id: 'file-api-recovered', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(), + filename: 'dsh-recovered.png', mime_type: 'image/png', }), { status: 200 })) } if (init?.method === 'DELETE') { return Promise.resolve(new Response(JSON.stringify({ - id: 'file-api-old', object: 'file', deleted: true, + id: 'file-api-old', type: 'file_deleted', }), { status: 200 })) } - expect(new URL(requestUrl(input)).pathname).toBe('/files') + expect(new URL(requestUrl(input)).pathname).toBe('/v1/files') return Promise.resolve(new Response(JSON.stringify({ - object: 'list', data: [{ - id: 'file-api-old', object: 'file', bytes: 3, created_at: NOW / 1_000, - filename: 'dsh-old.png', purpose: 'user_data', + id: 'file-api-old', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(), + filename: 'dsh-old.png', mime_type: 'image/png', }], first_id: 'file-api-old', last_id: 'file-api-old', has_more: false, }), { status: 200 })) @@ -492,10 +478,9 @@ describe('DeepSeekFileStore', () => { error: { message: 'file count quota exceeded', code: 'file_quota' }, }), { status: 400 })) return Promise.resolve(new Response(JSON.stringify({ - object: 'list', data: [{ - id: 'file-api-foreign', object: 'file', bytes: 3, created_at: NOW / 1_000, - filename: 'foreign.png', purpose: 'user_data', + id: 'file-api-foreign', type: 'file', size_bytes: 3, created_at: new Date(NOW).toISOString(), + filename: 'foreign.png', mime_type: 'image/png', }], has_more: false, }), { status: 200 })) @@ -518,20 +503,19 @@ describe('DeepSeekFileStore', () => { if (init?.method === 'DELETE') { const id = target.pathname.split('/').at(-1) ?? '' deleted.add(id) - return new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 }) + return new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 }) } - const after = target.searchParams.get('after') + const after = target.searchParams.get('after_id') if (after !== null && deleted.has(after)) throw new Error('deleted cursor cannot be reused') const id = after === null ? 'file-api-oldest' : 'file-api-next' return new Response(JSON.stringify({ - object: 'list', data: [{ id, - object: 'file', - bytes: 3, - created_at: NOW / 1_000, + type: 'file', + size_bytes: 3, + created_at: new Date(NOW).toISOString(), filename: `dsh-${id}.png`, - purpose: 'user_data', + mime_type: 'image/png', }], first_id: id, last_id: id, @@ -556,12 +540,12 @@ describe('DeepSeekFileStore', () => { const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { if (init?.method === 'DELETE') { const id = requestUrl(input).split('/').at(-1) - return Promise.resolve(new Response(JSON.stringify({ id, object: 'file', deleted: true }), { status: 200 })) + return Promise.resolve(new Response(JSON.stringify({ id, type: 'file_deleted' }), { status: 200 })) } page += 1 const lastId = mode === 'missing' ? undefined : 'file-api-same' return Promise.resolve(new Response(JSON.stringify({ - object: 'list', data: [], has_more: true, + data: [], has_more: true, ...lastId === undefined ? {} : { last_id: lastId }, }), { status: 200 })) }) as typeof fetch diff --git a/packages/llm/llm-deepseek/tests/files-api.spec.ts b/packages/llm/llm-deepseek/tests/files-api.spec.ts index a23664c85c..daed0ebedf 100644 --- a/packages/llm/llm-deepseek/tests/files-api.spec.ts +++ b/packages/llm/llm-deepseek/tests/files-api.spec.ts @@ -2,13 +2,13 @@ import { once } from 'node:events' import { createServer } from 'node:http' import { describe, expect, it, vi } from 'vitest' import { LlmError, userAgent } from '@deepseek-ai/dsh-llm' -import { DeepSeekFileId } from '../src/common/file-id.ts' +import { DeepSeekFileId } from '../src/file-id.ts' import { DeepSeekFilesClient, DeepSeekFilesError, isFilesQuotaError, MAX_FILE_UPLOAD_BYTES, -} from '../src/common/files-api.ts' +} from '../src/files-api.ts' function requestUrl(input: string | URL | Request): string { if (typeof input === 'string') return input @@ -17,28 +17,19 @@ function requestUrl(input: string | URL | Request): string { function file(overrides: Record = {}) { return { - id: 'file-api-one', - object: 'file', - bytes: 3, - created_at: 1_700_000_000, - filename: 'image.png', - purpose: 'user_data', - expires_at: 1_700_604_800, + id: 'file-api-one', type: 'file', size_bytes: 3, + created_at: '2023-11-14T22:13:20.000Z', filename: 'image.png', mime_type: 'image/png', ...overrides, } } describe('DeepSeekFilesClient', () => { - function messagesFile(overrides: Record = {}) { - return { id: 'file-api-one', type: 'file', size_bytes: 3, created_at: '2026-09-12T13:02:46.677853363+00:00', filename: 'image.png', mime_type: 'image/png', ...overrides } - } - - describe.each(['messages', 'chat-completions'] as const)('%s successful response JSON', (protocol) => { + describe('successful response JSON', () => { it.each((['upload', 'list', 'retrieve', 'delete'] as const).flatMap(operation => ['', '{"id":'].map(body => ({ operation, body })), ))('rejects malformed JSON with operation and HTTP status: %j', async ({ operation, body }) => { const fetchImpl = vi.fn(async () => new Response(body, { status: 200 })) - const client = new DeepSeekFilesClient({ protocol, baseURL: 'https://files.example', apiKey: 'test-key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', apiKey: 'test-key', fetch: fetchImpl }) const operations = { upload: () => client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600 }), list: () => client.list(), @@ -59,7 +50,7 @@ describe('DeepSeekFilesClient', () => { it.each([new TypeError('body transport failed'), new DOMException('body aborted', 'AbortError')])( 'preserves a non-JSON body-read failure: %s', async (failure) => { - const client = new DeepSeekFilesClient({ protocol, baseURL: 'https://files.example', apiKey: 'test-key', + const client = new DeepSeekFilesClient({ baseURL: 'https://files.example', apiKey: 'test-key', fetch: async () => new Response(new ReadableStream({ start(controller) { controller.error(failure) } })), }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).rejects.toBe(failure) @@ -79,20 +70,20 @@ describe('DeepSeekFilesClient', () => { expect(form.get('purpose')).toBeNull() expect(form.get('expires_after[anchor]')).toBe('created_at') expect(form.get('expires_after[seconds]')).toBe('3600') - return new Response(JSON.stringify(messagesFile())) + return new Response(JSON.stringify(file())) }) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example/custom/route///', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example/custom/route///', apiKey: 'key', fetch: fetchImpl }) const uploaded = await client.upload({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600 }) - const createdAt = Math.floor(Date.parse('2026-09-12T13:02:46.677Z') / 1_000) - expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', purpose: 'user_data', expiresAt: createdAt + 3_600 }) + const createdAt = Math.floor(Date.parse('2023-11-14T22:13:20.000Z') / 1_000) + expect(uploaded).toEqual({ id: 'file-api-one', bytes: 3, createdAt, filename: 'image.png', expiresAt: createdAt + 3_600 }) }) it.each([ ['https://provider.example/anthropic/v1', 'https://provider.example/anthropic/v1/files'], ['https://provider.example/v1beta/', 'https://provider.example/v1beta/v1/files'], ])('resolves the Messages Files endpoint from %s', async (baseURL, expected) => { - const fetchImpl = vi.fn(async () => new Response(JSON.stringify(messagesFile()))) - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL, apiKey: 'key', fetch: fetchImpl }) + const fetchImpl = vi.fn(async () => new Response(JSON.stringify(file()))) + const client = new DeepSeekFilesClient({ baseURL, apiKey: 'key', fetch: fetchImpl }) await client.upload({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -102,42 +93,42 @@ describe('DeepSeekFilesClient', () => { expect(requestUrl(fetchImpl.mock.calls[0]![0])).toBe(expected) }) - it('maps Messages list cursors, file metadata and deletion without OpenAI-only fields', async () => { + it('maps Messages list cursors, file metadata and deletion with native metadata', async () => { const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = new URL(requestUrl(input)) if (url.search) { expect(Object.fromEntries(url.searchParams)).toEqual({ after_id: 'file-before', limit: '1000' }) - return new Response(JSON.stringify({ data: [messagesFile()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false })) + return new Response(JSON.stringify({ data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false })) } expect(url.pathname).toBe('/anthropic/v1/files/file-api-one') - return new Response(JSON.stringify(init?.method === 'DELETE' ? { id: 'file-api-one', type: 'file_deleted' } : messagesFile())) + return new Response(JSON.stringify(init?.method === 'DELETE' ? { id: 'file-api-one', type: 'file_deleted' } : file())) }) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://api.deepseek.com/anthropic', apiKey: 'key', fetch: fetchImpl }) - await expect(client.list({ after: DeepSeekFileId('file-before'), limit: 1_000, order: 'asc' })).resolves.toMatchObject({ data: [{ bytes: 3 }], hasMore: false }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/anthropic', apiKey: 'key', fetch: fetchImpl }) + await expect(client.list({ after: DeepSeekFileId('file-before'), limit: 1_000 })).resolves.toMatchObject({ data: [{ bytes: 3 }], hasMore: false }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one', bytes: 3 }) await expect(client.delete(DeepSeekFileId('file-api-one'))).resolves.toBeUndefined() }) it('accepts an empty Messages list with null cursors', async () => { - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', fetch: async () => new Response(JSON.stringify({ data: [], first_id: null, last_id: null, has_more: false })), }) await expect(client.list()).resolves.toEqual({ data: [], hasMore: false }) }) it.each(['first_id', 'last_id'])('rejects a Messages list with a numeric %s', async (cursor) => { - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', fetch: async () => new Response(JSON.stringify({ data: [], first_id: null, last_id: null, has_more: false, [cursor]: 1 })), }) await expect(client.list()).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) - it.for(['messages', 'chat-completions'] as const)('refuses a redirected %s Files request before contacting another origin', async (protocol, { onTestFinished }) => { + it('refuses a redirected Files request before contacting another origin', async ({ onTestFinished }) => { const forwarded: string[] = [] const origins: string[] = [] const destination = createServer((request, response) => { forwarded.push(String(request.headers['x-api-key'] ?? request.headers.authorization)) - response.end(JSON.stringify(protocol === 'messages' ? messagesFile() : file())) + response.end(JSON.stringify(file())) }) const source = createServer((_request, response) => { response.writeHead(307, { location: `${origins[0]}/file-api-one` }) @@ -159,37 +150,37 @@ describe('DeepSeekFilesClient', () => { if (address === null || typeof address === 'string') throw new Error('expected a TCP server address') origins.push(`http://127.0.0.1:${address.port}`) } - const client = new DeepSeekFilesClient({ protocol, baseURL: origins[1]!, apiKey: 'redirect-test-key' }) + const client = new DeepSeekFilesClient({ baseURL: origins[1]!, apiKey: 'redirect-test-key' }) const error = await client.retrieve(DeepSeekFileId('file-api-one')).catch((cause: unknown) => cause) expect(forwarded).toEqual([]) expect(error).toMatchObject({ code: 'TRANSPORT' }) }) it.each([null, [], { type: 'wrong' }, { mime_type: null }, { created_at: 'invalid' }, { created_at: 123 }, { size_bytes: -1 }])('rejects malformed Messages file metadata %#', async (value) => { - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key', - fetch: async () => new Response(JSON.stringify(value === null || Array.isArray(value) ? value : messagesFile(value))), + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', + fetch: async () => new Response(JSON.stringify(value === null || Array.isArray(value) ? value : file(value))), }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) it('rejects a Messages deletion that returns a different file identity', async () => { - const client = new DeepSeekFilesClient({ protocol: 'messages', baseURL: 'https://gateway.example', apiKey: 'key', + const client = new DeepSeekFilesClient({ baseURL: 'https://gateway.example', apiKey: 'key', fetch: async () => new Response(JSON.stringify({ id: 'wrong', type: 'file_deleted' })), }) await expect(client.delete(DeepSeekFileId('file-api-one'))).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) }) - it('uploads multipart bytes with the required purpose and explicit expiry', async () => { + it('uploads multipart bytes with attribution and explicit expiry', async () => { const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { - expect(requestUrl(url)).toBe('https://api.deepseek.com/files') + expect(requestUrl(url)).toBe('https://api.deepseek.com/v1/files') expect(init?.method).toBe('POST') const headers = new Headers(init?.headers) - expect(headers.get('authorization')).toBe('Bearer key') + expect(headers.get('x-api-key')).toBe('key') expect(headers.get('user-agent')).toBe(userAgent()) const form = init?.body expect(form).toBeInstanceOf(FormData) if (!(form instanceof FormData)) throw new Error('expected multipart body') - expect(form.get('purpose')).toBe('user_data') + expect(form.get('purpose')).toBeNull() expect(form.get('expires_after[anchor]')).toBe('created_at') expect(form.get('expires_after[seconds]')).toBe('604800') const blob = form.get('file') @@ -197,7 +188,7 @@ describe('DeepSeekFilesClient', () => { expect((blob as Blob).size).toBe(3) return new Response(JSON.stringify(file()), { status: 200 }) }) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com/', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com/', apiKey: 'key', fetch: fetchImpl }) await expect(client.upload({ data: Uint8Array.of(1, 2, 3), @@ -209,7 +200,6 @@ describe('DeepSeekFilesClient', () => { bytes: 3, createdAt: 1_700_000_000, filename: 'image.png', - purpose: 'user_data', expiresAt: 1_700_604_800, }) }) @@ -219,40 +209,28 @@ describe('DeepSeekFilesClient', () => { const target = requestUrl(url) if (target.includes('?')) { return new Response(JSON.stringify({ - object: 'list', data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false, + data: [file()], first_id: 'file-api-one', last_id: 'file-api-one', has_more: false, }), { status: 200 }) } if (init?.method === 'DELETE') { - return new Response(JSON.stringify({ id: 'file-api-one', object: 'file', deleted: true }), { status: 200 }) + return new Response(JSON.stringify({ id: 'file-api-one', type: 'file_deleted' }), { status: 200 }) } return new Response(JSON.stringify(file()), { status: 200 }) }) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) - await expect(client.list({ after: DeepSeekFileId('file-api-before'), limit: 20, order: 'desc' })).resolves.toMatchObject({ + await expect(client.list({ after: DeepSeekFileId('file-api-before'), limit: 20 })).resolves.toMatchObject({ data: [{ id: 'file-api-one' }], firstId: 'file-api-one', lastId: 'file-api-one', hasMore: false, }) await expect(client.retrieve(DeepSeekFileId('file-api-one'))).resolves.toMatchObject({ id: 'file-api-one' }) await expect(client.delete(DeepSeekFileId('file-api-one'))).resolves.toBeUndefined() }) - it('refuses an upload response that omits the requested expiry', async () => { - const fetchImpl = vi.fn(() => Promise.resolve(new Response( - JSON.stringify(file({ expires_at: undefined })), - { status: 200 }, - ))) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) - - await expect(client.upload({ - data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, - })).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }) - }) - it('retains quota error detail for the one cleanup retry policy', async () => { const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ error: { message: 'user storage quota exceeded', type: 'invalid_request_error', code: 'file_quota' }, }), { status: 400 }))) as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) const error = await client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -269,7 +247,6 @@ describe('DeepSeekFilesClient', () => { [400, 'FILES_API'], ] as const)('classifies HTTP %i Files failures as %s', async (status, code) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response('not-json', { status }))), @@ -290,7 +267,6 @@ describe('DeepSeekFilesClient', () => { { error: { message: 1, type: 2, code: 3 } }, ])('falls back to the HTTP status for an unstructured provider error %#', async (body) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 400 }))), @@ -303,7 +279,6 @@ describe('DeepSeekFilesClient', () => { it('wraps transport failures but preserves an aborted request reason', async () => { const transport = new Error('socket closed') const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.reject(transport)), @@ -324,19 +299,15 @@ describe('DeepSeekFilesClient', () => { [], file({ id: 1 }), file({ id: '' }), - file({ object: 'wrong' }), - file({ bytes: 1.5 }), - file({ bytes: -1 }), + file({ type: 'wrong' }), + file({ size_bytes: 1.5 }), + file({ size_bytes: -1 }), file({ created_at: 1.5 }), file({ created_at: -1 }), file({ filename: 1 }), file({ filename: '' }), - file({ purpose: 'assistants' }), - file({ expires_at: 1.5 }), - file({ expires_at: -1 }), ])('rejects an invalid file object %#', async (body) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), @@ -350,7 +321,7 @@ describe('DeepSeekFilesClient', () => { 3_600.5, ])('refuses invalid file expiry %s before transport', async (expiresAfterSeconds) => { const fetchImpl = vi.fn() as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) await expect(client.upload({ data: Uint8Array.of(1), mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds, })).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) @@ -359,7 +330,7 @@ describe('DeepSeekFilesClient', () => { it('refuses a file larger than the upload limit before transport', async () => { const fetchImpl = vi.fn() as typeof fetch - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: fetchImpl }) const data = { byteLength: MAX_FILE_UPLOAD_BYTES + 1 } as Uint8Array await expect(client.upload({ data, mediaType: 'image/png', filename: 'image.png', expiresAfterSeconds: 3_600, @@ -371,14 +342,12 @@ describe('DeepSeekFilesClient', () => { null, [], {}, - { object: 'wrong', data: [], has_more: false }, - { object: 'list', data: null, has_more: false }, - { object: 'list', data: [], has_more: 0 }, - { object: 'list', data: [], has_more: false, first_id: 1 }, - { object: 'list', data: [], has_more: false, last_id: 1 }, + { data: null, has_more: false }, + { data: [], has_more: 0 }, + { data: [], has_more: false, first_id: 1 }, + { data: [], has_more: false, last_id: 1 }, ])('rejects an invalid list response %#', async (body) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), @@ -388,11 +357,11 @@ describe('DeepSeekFilesClient', () => { it('accepts a list without cursors and uses the global fetch default', async () => { const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ - object: 'list', data: [], has_more: false, + data: [], has_more: false, }), { status: 200 }))) vi.stubGlobal('fetch', fetchImpl) try { - const client = new DeepSeekFilesClient({ protocol: 'chat-completions', baseURL: 'https://api.deepseek.com///', apiKey: 'key' }) + const client = new DeepSeekFilesClient({ baseURL: 'https://api.deepseek.com///', apiKey: 'key' }) await expect(client.list()).resolves.toEqual({ data: [], hasMore: false }) } finally { vi.unstubAllGlobals() @@ -403,12 +372,10 @@ describe('DeepSeekFilesClient', () => { null, [], {}, - { id: 'wrong', object: 'file', deleted: true }, - { id: 'file-api-one', object: 'wrong', deleted: true }, - { id: 'file-api-one', object: 'file', deleted: false }, + { id: 'wrong', type: 'file_deleted' }, + { id: 'file-api-one', type: 'wrong' }, ])('rejects an invalid delete response %#', async (body) => { const client = new DeepSeekFilesClient({ - protocol: 'chat-completions', baseURL: 'https://api.deepseek.com', apiKey: 'key', fetch: vi.fn(() => Promise.resolve(new Response(JSON.stringify(body), { status: 200 }))), diff --git a/packages/llm/llm-deepseek/tests/messages/files.spec.ts b/packages/llm/llm-deepseek/tests/files.spec.ts similarity index 91% rename from packages/llm/llm-deepseek/tests/messages/files.spec.ts rename to packages/llm/llm-deepseek/tests/files.spec.ts index e4f1a9060e..aee62c111b 100644 --- a/packages/llm/llm-deepseek/tests/messages/files.spec.ts +++ b/packages/llm/llm-deepseek/tests/files.spec.ts @@ -1,16 +1,17 @@ /** Messages file-reference admission, bounded recovery and request-wide inline fallback. */ +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { afterEach, describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createAssistantMessage, createToolResultMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' -import { DeepSeekFileId } from '../../src/common/file-id.ts' -import type { DeepSeekFileStore } from '../../src/common/file-store.ts' -import { resolveAdapterOptions } from '../../src/config.ts' -import type { Config } from '../../src/config.ts' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import { prepareImages } from '../../src/protocols/messages/images.ts' -import { providerErrorDetail } from '../../src/protocols/messages/transport.ts' -import { chunks, options, prepareExtensions, sse, textEvents, user } from './helpers.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' +import { resolveAdapterOptions } from '../src/config.ts' +import type { Config } from '../src/config.ts' +import { DeepSeekAdapter } from '../src/adapter.ts' +import { prepareImages } from '../src/images.ts' +import { providerErrorDetail } from '../src/transport.ts' +import { chunks, options, prepareExtensions, sse, textEvents, user, requestImageStore } from './helpers.ts' const model = 'deepseek-flash' const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, mediaType: 'image/png', bytes: 3 } @@ -31,15 +32,15 @@ function harness(config: Config = {}) { record: { fileId: DeepSeekFileId(image.attachment.attachmentId === ref.attachmentId ? 'file-a' : 'file-b') }, uploaded: false, } as Awaited>)) const invalidate = vi.fn(async () => {}) - const files = { ensureUploaded, invalidate } as unknown as DeepSeekFileStore + const files = Object.assign(new DeepSeekFileStore(), { ensureUploaded, invalidate }) const readImageRequest = vi.fn(async (attachment: ImageAttachmentRef) => version(attachment)) // The codec and remote upload are the expensive boundaries; request projection and recovery stay real. - const attachments = { readImageRequest } as unknown as AttachmentStore + const attachments = requestImageStore(readImageRequest) const prepare = vi.fn(prepareExtensions) - const adapter = new DeepSeekMessagesAdapter({ - connection: () => resolveAdapterOptions(Object.assign({ baseURL: 'https://gateway.example/custom' }, config)), - apiKey: async () => 'test-key', userId: () => 'test-user', attachments: () => attachments, - imageAccess: () => ({ readonlyPath: '/workspace/image.png' }), files: () => files, prepareExtensions: prepare, + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions(Object.assign({ baseURL: 'https://gateway.example/custom' }, config)), + resolveApiKey: async () => 'test-key', resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => attachments, + resolveImageAccess: () => ({ readonlyPath: '/workspace/image.png' }), resolveFiles: () => files, prepareExtensions: prepare, }) return { adapter, ensureUploaded, invalidate, readImageRequest, prepare } } @@ -55,7 +56,7 @@ describe('Messages Files requests', () => { createToolResultMessage({ callId, isError: false, content: [{ type: 'image', attachment: ref }, { type: 'image', attachment: ref }] })] await chunks(h.adapter.stream(options({ model, messages }))) expect(h.readImageRequest).toHaveBeenCalledTimes(1) - expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key', protocol: 'messages' }, expect.anything(), expect.any(AbortSignal)) + expect(h.ensureUploaded).toHaveBeenCalledWith(expect.anything(), { baseURL: 'https://gateway.example/custom', apiKey: 'test-key' }, expect.anything(), expect.any(AbortSignal)) const [url, init] = fetchImpl.mock.calls[0]! expect(url).toBe('https://gateway.example/custom/v1/messages') expect(new Headers(init?.headers).get('anthropic-beta')).toBe('files-api-2025-04-14') @@ -72,7 +73,7 @@ describe('Messages Files requests', () => { vi.stubGlobal('fetch', fetchImpl) const h = harness() await chunks(h.adapter.stream(request([ref, second]))) - expect(h.invalidate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ attachment: ref }), 'file-a', expect.objectContaining({ protocol: 'messages' })) + expect(h.invalidate).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ attachment: ref }), 'file-a', expect.objectContaining({})) expect(fetchImpl).toHaveBeenCalledTimes(2) expect(h.prepare).toHaveBeenCalledTimes(2) expect(h.ensureUploaded).toHaveBeenCalledTimes(4) diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/cordis.yml b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml similarity index 100% rename from packages/llm/llm-deepseek/tests/messages/fixtures/cordis.yml rename to packages/llm/llm-deepseek/tests/fixtures/cordis.yml diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts b/packages/llm/llm-deepseek/tests/fixtures/history.ts similarity index 77% rename from packages/llm/llm-deepseek/tests/messages/fixtures/history.ts rename to packages/llm/llm-deepseek/tests/fixtures/history.ts index 4c5b78c6d1..0a02332dfc 100644 --- a/packages/llm/llm-deepseek/tests/messages/fixtures/history.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/history.ts @@ -1,7 +1,7 @@ /** Exercise Messages request conversion with recorded responses from another protocol. */ import type { Context } from '@deepseek-ai/cordis' -import { resolveAdapterOptions } from '../../../src/config.ts' -import { serialize } from '../../../src/protocols/messages/serialize.ts' +import { resolveAdapterOptions } from '../../src/config.ts' +import { serialize } from '../../src/serialize.ts' export const name = 'messages-history-snapshot' export const inject = ['llm'] diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts b/packages/llm/llm-deepseek/tests/fixtures/input-history.ts similarity index 93% rename from packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts rename to packages/llm/llm-deepseek/tests/fixtures/input-history.ts index ce98439370..33f45f6291 100644 --- a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts +++ b/packages/llm/llm-deepseek/tests/fixtures/input-history.ts @@ -3,8 +3,8 @@ import assert from 'node:assert/strict' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' -import { resolveAdapterOptions } from '../../../src/config.ts' -import { serialize } from '../../../src/protocols/messages/serialize.ts' +import { resolveAdapterOptions } from '../../src/config.ts' +import { serialize } from '../../src/serialize.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { diff --git a/packages/llm/llm-deepseek/tests/messages/helpers.ts b/packages/llm/llm-deepseek/tests/helpers.ts similarity index 63% rename from packages/llm/llm-deepseek/tests/messages/helpers.ts rename to packages/llm/llm-deepseek/tests/helpers.ts index 1aff3c74f6..d5f8e780d7 100644 --- a/packages/llm/llm-deepseek/tests/messages/helpers.ts +++ b/packages/llm/llm-deepseek/tests/helpers.ts @@ -1,14 +1,18 @@ /** Deterministic Messages fixtures and loopback transport with explicit teardown. */ +import { Context } from '@deepseek-ai/cordis' +import AttachmentStore from '@deepseek-ai/dsh-attachment' +import type { ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader' +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { createServer } from 'node:http' import type { IncomingHttpHeaders, ServerResponse } from 'node:http' import { once } from 'node:events' -import { object } from '../../src/protocols/messages/replay.ts' +import { object } from '../src/replay.ts' import { BlockAssembler, createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import { resolveAdapterOptions } from '../../src/index.ts' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import type { Config } from '../../src/config.ts' -import { DeepSeekFileStore } from '../../src/common/file-store.ts' +import { resolveAdapterOptions } from '../src/index.ts' +import { DeepSeekAdapter } from '../src/adapter.ts' +import type { Config } from '../src/config.ts' +import { DeepSeekFileStore } from '../src/file-store.ts' export const prepareExtensions = async () => ({ fields: {}, accept: async () => {} }) @@ -40,7 +44,7 @@ export async function assemble(stream: AsyncIterable, model = MODEL } export function adapter(config: Config = {}) { const files = new DeepSeekFileStore() - return new DeepSeekMessagesAdapter({ connection: () => resolveAdapterOptions(config), apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions }) + return new DeepSeekAdapter({ options: () => resolveAdapterOptions(config), resolveApiKey: () => Promise.resolve('test-key'), resolveUserId: () => 'test-user' as AnonymousUserId, resolveAttachments: () => undefined, resolveImageAccess: () => undefined, resolveFiles: () => files, prepareExtensions }) } export async function server(reply: (response: ServerResponse, count: number) => void = response => response.end(sse(textEvents))) { const requests: { path: string; headers: IncomingHttpHeaders; body: Record }[] = [] @@ -70,3 +74,28 @@ export async function server(reply: (response: ServerResponse, count: number) => }, } } + +/** Supply only the image projection operation; any unexpected storage work fails. */ +export function requestImageStore(readImageRequest: AttachmentStore['readImageRequest']): AttachmentStore { + class ProjectedAttachments extends AttachmentStore { + get imageLimits(): never { throw new Error('unexpected image policy read') } + validateImage(): never { throw new Error('unexpected image validation') } + saveImage(): never { throw new Error('unexpected image save') } + readImage(): never { throw new Error('unexpected original image read') } + override readImageRequest = readImageRequest + } + return new ProjectedAttachments(new Context()) +} + +/** Import mapped source modules without claiming support for Node's HMR internals. */ +export function sourceModuleLoader(importModule: (specifier: string) => Promise): ModuleLoaderV2 { + return { + version: 'v2', + import: importModule, + get loadCache(): never { throw new Error('unexpected module cache access') }, + register(): never { throw new Error('unexpected module hook registration') }, + getOrCreateModuleJob(): never { throw new Error('unexpected module job creation') }, + resolveSync(): never { throw new Error('unexpected synchronous module resolution') }, + load(): never { throw new Error('unexpected module load') }, + } +} diff --git a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts index 5825de033e..0796e3d113 100644 --- a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts +++ b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/common/image-tokens.ts' +import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/image-tokens.ts' describe('DeepSeek image tokens', () => { // Reference values from the provider's published image token calculator diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 146fd111d4..0626186629 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -29,14 +29,13 @@ import * as DeepSeekPluginPackageInventory from '@deepseek-ai/dsh-plugin-package import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' -import { server as messagesServer } from './messages/helpers.ts' +import { sourceModuleLoader } from './helpers.ts' const NS = 'llm-deepseek' const KEY_REF = credentialRef('DEEPSEEK_API_KEY') let root: string | undefined let context: Context | undefined -const closeMessagesServers: (() => Promise)[] = [] afterEach(async () => { await context?.fiber.dispose() @@ -44,12 +43,11 @@ afterEach(async () => { if (root !== undefined) await rm(root, { recursive: true, force: true }) root = undefined await closeMockServers() - while (closeMessagesServers.length) await closeMessagesServers.pop()!() vi.unstubAllEnvs() }) async function loadComposition( - options: { withDynamic: boolean; baseURL: string; reuseRoot?: string; enableSessionLog?: boolean; protocol?: 'chat-completions' | 'messages' }, + options: { withDynamic: boolean; baseURL: string; reuseRoot?: string; enableSessionLog?: boolean }, ): Promise<{ ctx: Context; settingsPath: string; credentialsPath: string }> { // A reused root is the restart case: the same harness home, its documents // exactly as the previous process left them. @@ -97,7 +95,6 @@ async function loadComposition( '- id: llm-deepseek', " name: '@deepseek-ai/dsh-llm-deepseek'", ' config:', - ` protocol: ${options.protocol ?? 'chat-completions'}`, ` baseURL: ${JSON.stringify(options.baseURL)}`, '', ].join('\n')) @@ -129,13 +126,10 @@ async function loadComposition( type: 'module', })}\n`) })) - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable + ctx.loader.internal = sourceModuleLoader(async (specifier) => { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href }, @@ -144,18 +138,12 @@ async function loadComposition( return { ctx, settingsPath, credentialsPath } } -async function extensionServer(protocol: 'chat-completions' | 'messages') { - if (protocol === 'chat-completions') return mockServer([{ kind: 'sse', events: textEvents }]) - const server = await messagesServer() - closeMessagesServers.push(() => server.close()) - return { url: server.url, get requests() { return server.requests.map(request => request.body) } } -} describe('llm-deepseek real dynamic composition', () => { - it.each(['chat-completions', 'messages'] as const)('keeps package inventory on when the %s Loader composition disables session upload', async (protocol) => { + it('keeps package inventory on when the Loader composition disables session upload', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') - const server = await extensionServer(protocol) - const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, protocol, enableSessionLog: false }) + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, enableSessionLog: false }) const session = ctx.sessions.create(SessionId('extension-composition')) session.append('turn/start', { turn: 1 }) @@ -171,13 +159,12 @@ describe('llm-deepseek real dynamic composition', () => { expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(-1) }) - it.each(['chat-completions', 'messages'] as const)('sends the canonical session suffix by default through %s Loader composition', async (protocol) => { + it('sends the canonical session suffix by default through Loader composition', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key') - const server = await extensionServer(protocol) + const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url, - protocol, }) const session = ctx.sessions.create(SessionId('extension-composition-enabled')) session.append('turn/start', { turn: 1 }) @@ -210,7 +197,7 @@ describe('llm-deepseek real dynamic composition', () => { expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS]) await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key') + expect(serverA.headers[0]?.['x-api-key']).toBe('boot-key') expect(serverA.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) // External edits, exactly as a user or the web UI would leave them on disk. @@ -225,7 +212,7 @@ describe('llm-deepseek real dynamic composition', () => { await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(serverA.requests).toHaveLength(1) - expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key') + expect(serverB.headers[0]?.['x-api-key']).toBe('rotated-key') }) it('keeps a stored key writable and rotatable across a real restart', async () => { @@ -240,7 +227,7 @@ describe('llm-deepseek real dynamic composition', () => { expect(await boot.ctx.get('credentials')!.describe(KEY_REF)) .toEqual({ configured: true, source: 'file', writable: true }) await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui') + expect(first.headers[0]?.['x-api-key']).toBe('stored-by-ui') await boot.ctx.fiber.dispose() context = undefined @@ -254,7 +241,7 @@ describe('llm-deepseek real dynamic composition', () => { // Rotation still works after the restart, and the next request uses it. await credentials.set(KEY_REF, 'rotated-after-restart') await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart') + expect(second.headers[0]?.['x-api-key']).toBe('rotated-after-restart') }) it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => { @@ -267,6 +254,6 @@ describe('llm-deepseek real dynamic composition', () => { expect(ctx.get('settings')).toBeUndefined() expect(ctx.get('credentials')).toBeUndefined() await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - expect(server.headers[0]?.authorization).toBe('Bearer entry-key') + expect(server.headers[0]?.['x-api-key']).toBe('entry-key') }) }) diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts deleted file mode 100644 index aeb6ebe3e8..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Real Messages round trips use the official root and require credentials. - * System-update checks additionally require DEEPSEEK_IN_HISTORY_MODEL. - */ -import { randomUUID } from 'node:crypto' -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, LoggerLevel } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import LocalAttachments from '@deepseek-ai/dsh-attachment-local' -import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import LlmRuntime, { BlockAssembler, createAssistantMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { Message } from '@deepseek-ai/dsh-llm' -import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' -import * as Messages from '../../src/index.ts' -import { DeepSeekFilesClient } from '../../src/common/files-api.ts' -import { MESSAGES_FILES_BETA } from '../../src/common/messages-api.ts' -import { assemble, options, user } from './helpers.ts' - -const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - while (cleanups.length) await cleanups.pop()!() - vi.unstubAllEnvs() - vi.unstubAllGlobals() -}) -async function boot(models?: Messages.Config['models']) { - const home = await mkdtemp(join(tmpdir(), 'dsh-messages-e2e-')) - cleanups.push(() => rm(home, { recursive: true, force: true })) - vi.stubEnv('DSH_HOME', home) - const ctx = new Context() - cleanups.push(() => ctx.fiber.dispose()) - await ctx.plugin(LlmRuntime) - await ctx.plugin(Messages, { - baseURL: Messages.MESSAGES_BASE_URL, - maxTokens: 4096, - ...models === undefined ? {} : { models }, - }) - return ctx -} -const tool = { name: 'lookup_value', description: 'Read the requested value. Always call this tool to obtain a value.', parameters: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] } } - -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', () => { - it.skipIf(!IN_HISTORY_MODEL).each([false, true])('updates system instructions during a conversation, in-history=%s', async (inHistory) => { - const model = IN_HISTORY_MODEL as string - // Each case owns the capability, even for a model with an in-history catalog default. - const ctx = await boot([{ id: model, ...inHistory ? { systemPromptUpdate: 'in-history' as const } : {} }]) - const history: Message[] = [createSystemMessage('Reply to every user message with exactly PROMPT_FIRST.'), user('Answer now.')] - const reply = async (expected: string) => { - const saved = JSON.stringify(history) - const response = await assemble(ctx.llm.stream(options({ model, messages: history, reasoningEffort: ReasoningEffortId('high') })), model) - expect(response.assembler.finish.kind).toBe('stop') - expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain(expected) - expect(JSON.stringify(history)).toBe(saved) - history.push(response.message) - } - await reply('PROMPT_FIRST') - history.push(createSystemMessage('Reply to every user message with exactly PROMPT_SECOND.'), user('Answer again.')) - await reply('PROMPT_SECOND') - const withoutSystem = history.filter(message => message.role !== 'system') - history.splice(0, history.length, createSystemMessage(''), ...withoutSystem, user('Reply with exactly PROMPT_CLEARED.')) - await reply('PROMPT_CLEARED') - }) - - it('uploads, lists, retrieves, reuses, and replaces a deleted Files image across Messages requests', async () => { - const ctx = await boot() - await ctx.plugin(LocalAttachments) - const fetchImpl = globalThis.fetch - const uploads: string[] = [] - const bodies: string[] = [] - const files = new DeepSeekFilesClient({ baseURL: Messages.MESSAGES_BASE_URL, protocol: 'messages', apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl }) - const ownedFiles = new Set>() - cleanups.push(async () => { - for (const id of ownedFiles) await files.delete(id) - }) - vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - const response = await fetchImpl(input, init) - if (url === `${Messages.MESSAGES_BASE_URL}/v1/files` && init?.method === 'POST' && response.ok) { - const file = await response.clone().json() as { id: string } - uploads.push(file.id) - ownedFiles.add(Messages.DeepSeekFileId(file.id)) - } - if (url === `${Messages.MESSAGES_BASE_URL}/v1/messages`) { - expect(new Headers(init?.headers).get('anthropic-beta')).toBe(MESSAGES_FILES_BETA) - if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') - bodies.push(init.body) - } - return response - }) - const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) - const message = user('What is the dominant color of this image? Reply with one English color word.') - const request = options({ - model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('off'), - messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }], - }) - for (let run = 0; run < 2; run++) { - const result = await assemble(ctx.llm.stream(request), request.model) - expect(result.assembler.finish.kind).toBe('stop') - expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red') - } - expect(uploads).toHaveLength(1) - expect(bodies).toHaveLength(2) - expect(bodies.every(body => body.includes(`"file_id":"${uploads[0]}"`) && !body.includes('"type":"base64"'))).toBe(true) - const fileId = Messages.DeepSeekFileId(uploads[0]!) - const retrieved = await files.retrieve(fileId) - expect(retrieved).toMatchObject({ id: fileId, bytes: attachment.bytes, purpose: 'user_data' }) - expect(retrieved.expiresAt).toBeUndefined() - let page = await files.list({ limit: 1_000 }) - const cursors = new Set() - while (!page.data.some(file => file.id === fileId) && page.hasMore) { - expect(page.lastId).toBeDefined() - const after = page.lastId! - expect(cursors.has(after)).toBe(false) - cursors.add(after) - page = await files.list({ after, limit: 1_000 }) - } - expect(page.data).toContainEqual(retrieved) - await files.delete(fileId) - ownedFiles.delete(fileId) - const recovered = await assemble(ctx.llm.stream(request), request.model) - expect(recovered.assembler.finish.kind).toBe('stop') - expect(recovered.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red') - expect(uploads).toHaveLength(2) - expect(uploads[1]).not.toBe(fileId) - expect(bodies).toHaveLength(4) - expect(bodies[2]).toContain(`"file_id":"${fileId}"`) - expect(bodies[3]).toContain(`"file_id":"${uploads[1]}"`) - expect(bodies[3]).not.toContain('"type":"base64"') - }) - - it.each([false, true])('submits Loader package inventory and records HTTP acceptance with session-log enabled=%s', async (enabled) => { - const ctx = await boot() - await ctx.plugin(Loader) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionStore) - await ctx.plugin(DeepSeekLlmApiExtensionRegistry) - await ctx.plugin(SessionLogDeepSeek, enabled ? {} : { enabled: false }) - ctx.baseUrl = import.meta.url - // Select the source module while Loader owns its active package entry. - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-plugin-package-inventory-deepseek') throw new Error(`unexpected Loader import: ${specifier}`) - return PluginPackageInventoryDeepSeek - }, - } as unknown as NonNullable - await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' }) - await ctx.loader.await() - const packageIdentity = JSON.parse(await readFile(new URL('../../../plugin-package-inventory-deepseek/package.json', import.meta.url), 'utf8')) as { name: string; version: string } - const session = ctx.sessions.create(SessionId(`real-messages-extensions-${randomUUID()}`)) - session.append('turn/start', { turn: 1 }) - const fetchImpl = globalThis.fetch - let afterSeq: number = -1 - let throughSeq = 0 - let requests = 0 - vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - if (url !== `${Messages.MESSAGES_BASE_URL}/v1/messages`) return fetchImpl(input, init) - if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request') - const body = JSON.parse(init.body) as Record - expect(body).toMatchObject({ dsh_plugin_packages: { - version: 1, packages: [{ name: packageIdentity.name, version: packageIdentity.version }], - } }) - if (enabled) { - expect(body).toMatchObject({ dsh_session_log: { - version: 1, sessionFormatVersion: session.header.version, session: { id: session.id }, - afterSeq, throughSeq, - events: Array.from({ length: throughSeq - afterSeq }, (_, index) => ({ seq: afterSeq + index + 1 })), - } }) - } else expect(body).not.toHaveProperty('dsh_session_log') - expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq) - const response = await fetchImpl(input, init) - expect(response.ok).toBe(true) - expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq) - requests += 1 - return response - }) - for (let run = 0; run < 2; run++) { - afterSeq = SessionLogDeepSeek.acceptedThrough(session) - throughSeq = Number(session.seq) - 1 - const assembler = new BlockAssembler() - for await (const chunk of ctx.llm.stream(options({ sessionId: session.id, reasoningEffort: ReasoningEffortId('off'), messages: [user('Reply with exactly PONG.')] }))) { - expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(enabled ? throughSeq : -1) - assembler.push(chunk) - } - expect(assembler.finish.kind).toBe('stop') - expect(assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG') - expect(requests).toBe(run + 1) - if (run === 0) session.append('step/start', { turn: 1, step: 1 }) - } - expect(session.snapshotEvents().filter(event => event.type === 'session-log-deepseek/delivery-accepted')).toHaveLength(enabled ? 2 : 0) - }) - - it.each(['off', 'low', 'high', 'max'])('streams text with %s effort', async (effort) => { - const ctx = await boot() - const result = await assemble(ctx.llm.stream(options({ reasoningEffort: ReasoningEffortId(effort), temperature: 0, messages: [user('Reply with exactly PONG.')] }))) - expect(result.assembler.finish).toEqual({ kind: 'stop' }) - expect(result.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG') - expect(result.assembler.usage?.outputTokens).toBeGreaterThan(0) - expect(result.message.source.replayState).toBeDefined() - }) - - it('replays a JSON-persisted thinking/tool turn and an error result before continuing', async () => { - const ctx = await boot() - const history: Message[] = [user('Use lookup_value with key secret. If the tool returns an error, report its exact error text and stop.')] - const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') }) - const first = await assemble(ctx.llm.stream(request)) - expect(first.assembler.finish.kind).toBe('tool-calls') - const calls = first.message.content.filter(block => block.type === 'tool-call') - expect(calls).toHaveLength(1) - const restored = JSON.parse(JSON.stringify(first.message)) as Message - history.push(restored) - for (const call of calls) history.push(createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'LOOKUP_DENIED_731' }], isError: true })) - const second = await assemble(ctx.llm.stream({ ...request, messages: history })) - expect(second.assembler.finish.kind).toBe('stop') - expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('LOOKUP_DENIED_731') - history.push(second.message, user('Reply with exactly DONE.')) - const third = await assemble(ctx.llm.stream({ ...request, messages: history })) - expect(third.assembler.finish.kind).toBe('stop') - }) - - it('continues persisted foreign history containing malformed tool arguments', async () => { - const ctx = await boot() - const callId = ToolCallId('historical_lookup') - const history = [ - user('Look up the secret value.'), - createAssistantMessage({ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, content: [ - { type: 'tool-call', id: callId, name: 'lookup_value', arguments: '{"key":"the "secret""}' }, - ] }), - createToolResultMessage({ callId, content: [{ type: 'text', text: 'Invalid arguments: expected an object' }], isError: true }), - user('Do not retry the lookup. Reply with exactly HISTORY_RECOVERED_731.'), - ] - const saved = JSON.stringify(history) - const restored = JSON.parse(saved) as Message[] - const response = await assemble(ctx.llm.stream(options({ messages: restored, tools: [tool], reasoningEffort: ReasoningEffortId('off') }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(response.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('HISTORY_RECOVERED_731') - expect(JSON.stringify(restored)).toBe(saved) - }) - - it('cancels an active stream without committing a successful response', async () => { - const ctx = await boot() - const controller = new AbortController() - const stream = ctx.llm.stream(options({ signal: controller.signal, messages: [user('List the integers from one to one thousand, one per line.')] })) - let cancelled = false - let finish: unknown - for await (const chunk of stream) { - if (!cancelled && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')) { cancelled = true; controller.abort() } - if (chunk.type === 'finish') finish = chunk.reason - } - expect(cancelled).toBe(true) - expect(finish).toMatchObject({ kind: 'aborted' }) - }) - - it('continues a thinking/tool turn after degrading unusable persisted replay metadata', async () => { - const ctx = await boot() - const warnings: unknown[][] = [] - ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) - const history: Message[] = [user('Use lookup_value with key secret. Then reply with the exact tool result and stop.')] - const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') }) - const first = await assemble(ctx.llm.stream(request)) - expect(first.assembler.finish.kind).toBe('tool-calls') - const calls = first.message.content.filter(block => block.type === 'tool-call') - expect(calls).toHaveLength(1) - const restored = JSON.parse(JSON.stringify(first.message)) as typeof first.message - restored.source.replayState = { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] } - const saved = JSON.stringify(restored) - history.push(restored, ...calls.map(call => createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'REPLAY_RECOVERED_731' }], isError: false }))) - const second = await assemble(ctx.llm.stream({ ...request, messages: history })) - expect(second.assembler.finish.kind).toBe('stop') - expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('REPLAY_RECOVERED_731') - expect(warnings).toEqual([[expect.stringContaining('unsupported kind or version')]]) - expect(JSON.stringify(restored)).toBe(saved) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts b/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts deleted file mode 100644 index 6a9fa25af6..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts +++ /dev/null @@ -1,450 +0,0 @@ -/** HTTP lifecycle, routing and optional Cordis services under real composition. */ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, LoggerLevel, Service } from '@deepseek-ai/cordis' -import LocalAttachments from '@deepseek-ai/dsh-attachment-local' -import AgentRegistry, { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime, { createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import LocalCredentials from '@deepseek-ai/dsh-credentials-local' -import FileSettings from '@deepseek-ai/dsh-settings-file' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import { DeepSeekMessagesAdapter } from '../../src/protocols/messages/adapter.ts' -import { DeepSeekFileStore } from '../../src/common/file-store.ts' -import * as Messages from '../../src/index.ts' -import { adapter, assemble, chunks, MODEL, options, prepareExtensions, server, sse, textEvents, user } from './helpers.ts' - -const cleanup: (() => Promise)[] = [] -afterEach(async () => { - while (cleanup.length) await cleanup.pop()!() - vi.unstubAllEnvs() - vi.unstubAllGlobals() -}) -async function endpoint(...args: Parameters) { - const instance = await server(...args) - cleanup.push(() => instance.close()) - return instance -} -async function context() { - const home = await mkdtemp(join(tmpdir(), 'dsh-messages-test-')) - cleanup.push(() => rm(home, { recursive: true, force: true })) - vi.stubEnv('DSH_HOME', home) - const ctx = new Context() - cleanup.push(() => ctx.fiber.dispose()) - return { ctx, home } -} - -async function send(agent: Agent, text: string) { - agent.followup(user(text)) - await agent.whenIdle() - expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) -} - -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - 'saved-notice': { kind: 'saved-notice' } - } -} - -describe('direct Messages HTTP', () => { - it('continues through Messages with assistant blocks in saved user history', async () => { - const http = await endpoint() - const notice = createUserMessage({ source: { kind: 'saved-notice' }, content: [ - { type: 'text', text: 'Background subagent finished.' }, - { type: 'reasoning', text: 'child reasoning' }, - { type: 'tool-call', id: ToolCallId('child-call'), name: 'read', arguments: '{}' }, - { type: 'text', text: 'Its closing message: answer.' }, - ] }) - const empty = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'reasoning', text: 'no closing text' }] }) - const history = [notice, empty] - const saved = JSON.stringify(history) - const llm = adapter({ baseURL: http.url }) - const first = await assemble(llm.stream(options({ messages: history }))) - const second = await assemble(llm.stream(options({ messages: [...history, first.message, user('continue')] }))) - - expect(first.assembler.finish.kind).toBe('stop') - expect(second.assembler.finish.kind).toBe('stop') - expect(http.requests).toHaveLength(2) - const wireNotice = { role: 'user', content: [ - { type: 'text', text: 'Background subagent finished.' }, - { type: 'text', text: 'Its closing message: answer.' }, - ] } - expect(http.requests[0]?.body.messages).toEqual([wireNotice]) - expect(http.requests[1]?.body.messages).toEqual([ - wireNotice, - { role: 'assistant', content: [{ type: 'text', text: 'Hello 世界' }] }, - { role: 'user', content: [{ type: 'text', text: 'continue' }] }, - ]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('continues without a diagnostic callback when replay metadata is unusable', async () => { - const http = await endpoint() - const message = createAssistantMessage({ content: [{ type: 'text', text: 'Remember 731.' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: { response: {}, blocks: [] }, - } }) - const response = await assemble(adapter({ baseURL: http.url }).stream(options({ messages: [user(), message, user()] }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.body.messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Remember 731.' }] }, - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - ]) - }) - - it('uses the Messages endpoint, authentication, attribution and final usage', async () => { - const http = await endpoint() - const llm = adapter({ baseURL: http.url }) - const response = await assemble(llm.stream(options({ model: 'deepseek-flash', sessionId: SessionId('session-test'), purpose: 'compaction' })), 'deepseek-flash') - expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) - expect(response.message.source).toMatchObject({ - model: 'deepseek-flash', replayState: { response: { model: 'deepseek-flash' } }, - }) - expect(http.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { - 'x-api-key': 'test-key', 'anthropic-version': '2023-06-01', - 'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user', - 'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1', - }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } }) - expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' }) - expect((await llm.listModels('deepseek-official')).map(model => model.id)).toEqual([ - 'deepseek-flash', 'deepseek-v4-pro', - ]) - expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({ - name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', - }) - expect(await llm.resolveModel('deepseek-official', MODEL)).toMatchObject({ id: MODEL }) - expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined() - }) - - it.each([ - ['https://provider.example', 'https://provider.example/v1/messages'], - ['https://provider.example/v1/', 'https://provider.example/v1/messages'], - ['https://provider.example/v1beta', 'https://provider.example/v1beta/v1/messages'], - ['https://provider.example/v2', 'https://provider.example/v2/v1/messages'], - ['https://provider.example/anthropic', 'https://provider.example/anthropic/v1/messages'], - ['https://v1.provider.example', 'https://v1.provider.example/v1/messages'], - ])('resolves the Messages endpoint from %s', async (baseURL, expected) => { - const fetchImpl = vi.fn(async () => new Response(sse(textEvents), { - headers: { 'content-type': 'text/event-stream' }, - })) - vi.stubGlobal('fetch', fetchImpl) - - await chunks(adapter({ baseURL }).stream(options())) - - expect(fetchImpl.mock.calls[0]?.[0]).toBe(expected) - }) - - it.each([true, false])('maps non-2xx responses (JSON=%s)', async (json) => { - const http = await endpoint((response) => { response.statusCode = 429; response.setHeader('retry-after', '3'); response.end(json ? JSON.stringify({ error: { type: 'rate_limit_error', message: 'slow down' } }) : 'busy') }) - await expect(chunks(adapter({ baseURL: http.url }).stream(options()))).rejects.toMatchObject({ code: 'RATE_LIMIT', failure: { status: 429, providerRetryAfterMs: 3000 } }) - }) - - it('refuses redirects before credentials reach another origin or request extensions are accepted', async () => { - const destination = await endpoint() - const source = await endpoint((response) => { - response.writeHead(307, { location: `${destination.url}/v1/messages` }) - response.end() - }) - const accept = vi.fn(async () => {}) - const prepare = vi.fn(async () => ({ fields: {}, accept })) - const files = new DeepSeekFileStore() - const llm = new DeepSeekMessagesAdapter({ - connection: () => Messages.resolveAdapterOptions({ baseURL: source.url }), - apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user', - attachments: () => undefined, imageAccess: () => undefined, files: () => files, - prepareExtensions: prepare, - }) - const error = await chunks(llm.stream(options())).catch((cause: unknown) => cause) - expect(source.requests).toHaveLength(1) - expect(source.requests[0]?.headers['x-api-key']).toBe('test-key') - expect(destination.requests).toEqual([]) - expect(error).toMatchObject({ code: 'TRANSPORT' }) - expect(prepare).toHaveBeenCalledOnce() - expect(accept).not.toHaveBeenCalled() - }) - - it('freezes endpoint and defaults for a prepared call while the next call sees new settings', async () => { - const first = await endpoint(), second = await endpoint() - let config = Messages.resolveAdapterOptions({ baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) - const files = new DeepSeekFileStore() - const llm = new DeepSeekMessagesAdapter({ connection: () => config, apiKey: snapshot => Promise.resolve(snapshot.maxTokens === 10 ? 'first' : 'second'), userId: () => 'user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions }) - const prepared = await llm.prepareCall('deepseek-official', MODEL) - config = Messages.resolveAdapterOptions({ baseURL: second.url, maxTokens: 20 }) - expect(prepared.model.systemPromptUpdate).toBe('in-history') - expect((await llm.resolveModel('deepseek-official', MODEL)).systemPromptUpdate).toBeUndefined() - await chunks(prepared.stream(options())) - await chunks(llm.stream(options())) - expect(first.requests[0]).toMatchObject({ headers: { 'x-api-key': 'first' }, body: { max_tokens: 10 } }) - expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'second' }, body: { max_tokens: 20 } }) - }) - - it('aborts an open provider response when its consumer stops', async () => { - let closed!: () => void - const stopped = new Promise((resolve) => { closed = resolve }) - const http = await endpoint((response) => { - response.once('close', closed) - response.write(sse(textEvents.slice(0, 3))) - }) - const stream = adapter({ baseURL: http.url }).stream(options())[Symbol.asyncIterator]() - expect((await stream.next()).value).toMatchObject({ type: 'block-start' }) - await stream.return!() - await stopped - }) - - it('distinguishes caller cancellation from idle timeout and transport failure', async () => { - const http = await endpoint((response) =>{ response.flushHeaders() }) - await expect(chunks(adapter({ baseURL: http.url, streamIdleTimeoutMs: 30 }).stream(options()))).rejects.toMatchObject({ code: 'TIMEOUT' }) - const controller = new AbortController(); controller.abort() - await expect(chunks(adapter({ baseURL: http.url }).stream(options({ signal: controller.signal })))).rejects.toMatchObject({ code: 'ABORTED' }) - vi.stubGlobal('fetch', async () => { throw new TypeError('network down') }) - await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'TRANSPORT' }) - }) - - it('rejects a successful response with no readable body', async () => { - vi.stubGlobal('fetch', async () => new Response(null, { status: 200 })) - await expect(chunks(adapter().stream(options()))).rejects.toMatchObject({ code: 'EMPTY_RESPONSE' }) - }) -}) - -describe('Cordis provider composition', () => { - it('resolves an attachment service loaded after the adapter and maps its read-only path', async () => { - const http = await endpoint() - const { ctx, home } = await context() - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - await ctx.plugin(LlmRuntime) - await ctx.plugin(Messages, { baseURL: http.url }) - const model = 'deepseek-flash' - const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)! - const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const } - expect(price().priceImages([{ type: 'image', attachment: dummy }])[0]?.text).toBeDefined() - await ctx.plugin(LocalAttachments, { dshHome: home }) - const attachment = await ctx.attachments.saveImage({ data: await readFile(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) - expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).not.toContain('/mounted/image.png') - class MappedFiles extends Service { - constructor(context: Context) { super(context, 'fs') } - processPathFromHostPath(_path: string) { return '/mounted/image.png' } - } - await ctx.plugin(MappedFiles) - const message = user() - await chunks(ctx.llm.stream(options({ model, messages: [{ ...message, content: [...message.content, { type: 'image', attachment }] }] }))) - expect(JSON.stringify(http.requests[0]?.body)).toContain('/mounted/image.png') - expect(price().priceImages([{ type: 'image', attachment }])[0]?.text).toContain('/mounted/image.png') - }) - - async function boot(...args: Parameters) { - const http = await endpoint(...args) - const { ctx, home } = await context() - vi.stubEnv('DEEPSEEK_API_KEY', '') - await writeFile(join(home, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: stored-key\n', { mode: 0o600 }) - await writeFile(join(home, 'settings.yaml'), '{}\n') - const template = await readFile(new URL('fixtures/cordis.yml', import.meta.url), 'utf8') - await writeFile(join(home, 'cordis.yml'), template.replaceAll('{{endpoint}}', JSON.stringify(http.url)).replaceAll('{{settings}}', JSON.stringify(join(home, 'settings.yaml'))).replaceAll('{{credentials}}', JSON.stringify(join(home, '.credentials.yaml')))) - ctx.baseUrl = pathToFileURL(home).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - const modules = new Map([ - ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-llm-deepseek', Messages], - ['@deepseek-ai/dsh-credentials-local', LocalCredentials], ['@deepseek-ai/dsh-settings-file', FileSettings], - ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], - ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], - ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], - ]) - // The importer supplies source modules while Loader still owns configuration and effects. - for (const name of modules.keys()) { - const directory = join(home, 'node_modules', name) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '0.1.3-alpha.1', type: 'module' })) - } - ctx.loader.internal = { version: 'v2', async import(name: string) { - if (!modules.has(name)) throw new Error(`unexpected module ${name}`) - return modules.get(name) - } } as unknown as NonNullable - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(home, 'cordis.yml')).href } }) - await ctx.loader.await() - return { ctx, http } - } - - it.each([ - { model: MODEL, inHistory: false }, - { model: MODEL, inHistory: true }, - { model: 'deepseek-flash', inHistory: true }, - ])('updates, clears and restores prompts across continued and resumed sessions, model=$model in-history=$inHistory', async ({ model, inHistory }) => { - const { ctx, http } = await boot() - if (inHistory && model === MODEL) await ctx.settings.update(Messages.name, { models: [{ id: model, systemPromptUpdate: 'in-history' }] }) - let prompt = 'first prompt' - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ - ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], - })) - const agentOptions = { provider: 'deepseek-official', model } - const agent = await ctx.agentLoop.create(SessionId('prompt-update'), agentOptions) - await send(agent, 'first') - prompt = 'second prompt' - await send(agent, 'second') - const count = agent.session.snapshotEvents().filter(event => event.type === 'system/message').length - await send(agent, 'unchanged') - expect(agent.session.snapshotEvents().filter(event => event.type === 'system/message')).toHaveLength(count) - prompt = '' - await send(agent, 'clear') - const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('prompt-resume'), agentOptions, - seed: [...agent.session.snapshotEvents()] }) - await send(resumed, 'resume cleared') - prompt = 'restored prompt' - await send(resumed, 'restore') - expect(http.requests.map(request => request.body.system)).toEqual(inHistory - ? ['first prompt', 'first prompt', 'first prompt', undefined, undefined, undefined] - : ['first prompt', 'second prompt', 'second prompt', undefined, undefined, 'restored prompt']) - for (const [index, request] of http.requests.entries()) { - const messages = request.body.messages as { role: string; content: unknown[] }[] - expect(messages.filter(message => message.role === 'assistant')).toHaveLength(index) - expect(messages.filter(message => message.role === 'system').map(message => message.content)).toEqual( - !inHistory ? [] : index === 1 || index === 2 ? [[{ type: 'text', text: 'second prompt' }]] - : index === 5 ? [[{ type: 'text', text: 'restored prompt' }]] : [], - ) - expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/first prompt|second prompt|restored prompt/) - } - expect(resumed.session.requestContext()?.systemPromptUpdate).toBe(inHistory ? 'in-history' : undefined) - }) - - it.each([false, true])('continues and resumes Chat Completions sessions through Messages, in-history=%s', async (inHistory) => { - let messagesProtocol = false - const { ctx, http } = await boot(response => response.end(messagesProtocol ? sse(textEvents) : [ - 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', - 'data: [DONE]\n\n', - ].join(''))) - await ctx.settings.update('llm-deepseek', { protocol: 'chat-completions', baseURL: http.url, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) - let prompt = 'old prompt' - ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({ - ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }], - })) - const selection: ModelSelectionRef = { current: { provider: 'deepseek-official', model: MODEL }, assembled: undefined } - const agent = await ctx.agentLoop.create(SessionId('protocol-switch'), selection.current) - installModelSelection(agent.ctx, selection) - await send(agent, 'first') - prompt = 'current prompt' - await send(agent, 'second') - expect((http.requests[1]?.body.messages as { role: string }[]).filter(message => message.role === 'system')).toHaveLength(2) - const seed = [...agent.session.snapshotEvents()] - const saved = JSON.stringify(seed) - messagesProtocol = true - await ctx.settings.update(Messages.name, { protocol: 'messages', models: [{ id: MODEL, ...inHistory ? { systemPromptUpdate: 'in-history' } : {} }] }) - selection.current = { provider: 'deepseek-official', model: MODEL } - await send(agent, 'switch') - const { agent: resumed } = await ctx.agents.create({ sessionId: SessionId('switch-resume'), agentOptions: selection.current, seed }) - await send(resumed, 'resume') - for (const request of http.requests.slice(2)) { - expect(request.path).toBe('/anthropic/v1/messages') - expect(request.body.system).toBe(inHistory ? 'old prompt' : 'current prompt') - const messages = request.body.messages as { role: string }[] - expect(messages.filter(message => message.role === 'assistant')).toHaveLength(2) - expect(messages.filter(message => message.role === 'system')).toHaveLength(inHistory ? 1 : 0) - expect(JSON.stringify(messages.filter(message => message.role !== 'system'))).not.toMatch(/old prompt|current prompt/) - } - expect(JSON.stringify(seed)).toBe(saved) - expect(agent.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) - expect(resumed.session.deriveMessages().filter(message => message.role === 'system')).toHaveLength(inHistory ? 2 : 1) - }) - - it('maps multiple system snapshots on direct compaction calls to the latest prompt', async () => { - const { ctx, http } = await boot() - const history = [createSystemMessage('old'), user(), - createAssistantMessage({ content: [{ type: 'text', text: 'OK' }], source: { provider: 'deepseek-official', model: MODEL } }), - createSystemMessage('current'), user('summarize')] - const saved = JSON.stringify(history) - const response = await assemble(ctx.llm.stream(options({ messages: history, purpose: 'compaction' }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.body.system).toBe('current') - expect((http.requests[0]?.body.messages as { role: string }[]).map(message => message.role)).toEqual(['user', 'assistant', 'user']) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('continues a recorded tool turn with a warning when its native replay version is unknown', async () => { - const { ctx, http } = await boot() - const warnings: unknown[][] = [] - ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } }) - const fixture = await readFile(new URL('../../../../../snapshots/session/deepseek-messages-degraded-replay/session.v2.jsonl', import.meta.url), 'utf8') - const records = fixture.trim().split('\n').map(line => JSON.parse(line) as { type: string; data: { message?: Message } }) - const assistant = records.find(record => record.type === 'assistant/message')!.data.message! - if (assistant.source.kind === 'model') assistant.source.provider = 'deepseek-official' - // The released v2 row still wraps its tool result inside a user message. - const released = records.find(record => record.type === 'tool/result')!.data.message! as unknown as { - readonly content: readonly { - readonly type: string - readonly toolCallId: ToolCallId - readonly content: readonly ContentBlock[] - readonly isError?: boolean - }[] - } - const releasedBlock = released.content[0]! - const result = createToolResultMessage({ - callId: releasedBlock.toolCallId, content: [...releasedBlock.content], isError: releasedBlock.isError === true, - }) - const saved = JSON.stringify([assistant, result]) - const response = await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] }))) - expect(response.assembler.finish.kind).toBe('stop') - expect(warnings).toEqual([[`llm-deepseek: unusable Messages replay state on assistant history for route "deepseek-official/${MODEL}"; sending provider-neutral content (DeepSeek Messages replay: unsupported kind or version)`]]) - expect(http.requests).toHaveLength(1) - expect(http.requests[0]?.body.messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [ - { type: 'thinking', thinking: 'The user wants me to run a simple bash command and then reply with "DONE".' }, - { type: 'tool_use', id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', name: 'bash', input: { command: 'echo TERMINAL_OK', description: 'Echo TERMINAL_OK to verify terminal access' } }, - ] }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', content: [{ type: 'text', text: 'TERMINAL_OK\n' }], is_error: false }] }, - ]) - expect(JSON.stringify([assistant, result])).toBe(saved) - }) - - it('loads one provider from YAML, rotates settings and credentials, then removes disposed registrations', async () => { - const { ctx, http } = await boot() - expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek-official']) - expect((await assemble(ctx.llm.stream(options()))).assembler.finish.kind).toBe('stop') - expect(http.requests[0]?.headers['x-api-key']).toBe('stored-key') - const second = await endpoint() - await ctx.settings.update(Messages.name, { baseURL: second.url, maxTokens: 51, retryPolicy: { mode: 'always' } }) - await ctx.credentials.set(credentialRef('DEEPSEEK_API_KEY'), 'rotated') - await chunks(ctx.llm.stream(options())) - expect(second.requests[0]).toMatchObject({ headers: { 'x-api-key': 'rotated' }, body: { max_tokens: 51 } }) - await ctx.settings.update(Messages.name, { models: [{ id: 'duplicate' }, { id: 'duplicate' }], baseURL: http.url }) - await chunks(ctx.llm.stream(options())) - expect(second.requests).toHaveLength(2) - expect(http.requests).toHaveLength(1) - await ctx.settings.update(Messages.name, { models: [{ id: MODEL }], baseURL: http.url }) - await chunks(ctx.llm.stream(options())) - expect(http.requests).toHaveLength(2) - const llm = ctx.llm - await ctx.fiber.dispose() - expect(llm.listProviders()).toEqual([]) - expect(llm.listConfigurableProviders()).toEqual([]) - }) - - it('uses environment credentials and reports missing or malformed keys without network access', async () => { - const http = await endpoint() - const { ctx } = await context() - vi.stubEnv('DEEPSEEK_BASE_URL', http.url) - vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') - await ctx.plugin(LlmRuntime) - const fiber = ctx.plugin(Messages) - await fiber - await chunks(ctx.llm.stream(options())) - expect(http.requests[0]?.headers['x-api-key']).toBe('env-key') - vi.stubEnv('DEEPSEEK_API_KEY', '') - expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) - vi.stubEnv('DEEPSEEK_API_KEY', 'bad\nkey') - expect((await assemble(ctx.llm.stream(options()))).assembler.finish).toMatchObject({ kind: 'error', failure: { code: 'INVALID_CREDENTIAL' } }) - await fiber.dispose() - expect(ctx.llm.listProviders()).toEqual([]) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/red.png b/packages/llm/llm-deepseek/tests/messages/fixtures/red.png deleted file mode 100644 index 62a5f8f47f..0000000000 Binary files a/packages/llm/llm-deepseek/tests/messages/fixtures/red.png and /dev/null differ diff --git a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts deleted file mode 100644 index dbb1424056..0000000000 --- a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts +++ /dev/null @@ -1,454 +0,0 @@ -/** Request conversion and durable replay validation. */ -import { describe, expect, it, vi } from 'vitest' -import { createDeveloperMessage, createUserMessage, createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, ImageBlock, Message, RequestMessage, RequestUserInput } from '@deepseek-ai/dsh-llm' -import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { resolveAdapterOptions } from '../../src/config.ts' -import { modelInfo } from '../../src/common/model-info.ts' -import type { Config } from '../../src/config.ts' -import { imagePricing, inlineImages, prepareImages } from '../../src/protocols/messages/images.ts' -import { readReplay, replayState } from '../../src/protocols/messages/replay.ts' -import { serialize } from '../../src/protocols/messages/serialize.ts' -import { MODEL, options, user } from './helpers.ts' - -const connection = resolveAdapterOptions({}) -const call = (id = 'a'): ContentBlock => ({ type: 'tool-call', id: ToolCallId(id), name: 'read', arguments: '{"path":"a"}' }) -const assistant = (content: ContentBlock[]) => createAssistantMessage({ content, source: { provider: 'deepseek-official', model: MODEL } }) -const result = (id = 'a', content: ContentBlock[] = [{ type: 'text', text: 'result' }]) => createToolResultMessage({ callId: ToolCallId(id), content, isError: false }) -const body = (messages: RequestMessage[] = [user()], overrides: Partial = {}) => serialize( - options({ messages, ...overrides }), connection, messages, new Map(), () => undefined, -) -const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) -const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined) - -describe('Messages request conversion', () => { - it('rejects unknown plugin content without interpreting its payload', () => { - expect(() => body([createUserMessage({ source: { kind: 'user' }, content: [ - { type: 'plugin:text', text: 'opaque' } as never, - ] })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { - for (const type of ['tool-addition', 'tool-removal'] as const) { - const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message - expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - } - }) - - it('rejects deferred tool definitions until provider loading is implemented', () => { - expect(() => body([], { tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] })) - .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - - it('preserves the exact request with request-only text after durable tool results', () => { - const prefix = [user(), assistant([call()]), result()] - const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } - const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) - expect(body([...prefix, input], { system: 'policy' })).toEqual(body([...prefix, durable], { system: 'policy' })) - }) - - it('preserves request-only image content through image preparation and wire conversion', async () => { - const attachment: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), mediaType: 'image/png', bytes: 3, width: 1, height: 1, - } - const version: RequestImageAttachment = { - variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), attachment, data: Uint8Array.of(1, 2, 3), - mediaType: 'image/png', bytes: 3, width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: true, - } - const input: RequestUserInput = { role: 'user', content: [ - { type: 'text', text: 'before' }, { type: 'image', attachment }, { type: 'text', text: 'after' }, - ] } - const store = { readImageRequest: async () => version } as unknown as AttachmentStore - const vision = resolveAdapterOptions({ models: [{ id: MODEL, inputModalities: ['text', 'image'] }] }) - const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) - const actual = await prepareImages([input], vision, MODEL, store, () => undefined, new AbortController().signal) - const expected = await prepareImages([durable], vision, MODEL, store, () => undefined, new AbortController().signal) - expect(serialize(options({ messages: [input] }), vision, actual.messages, actual.versions, () => undefined)) - .toEqual(serialize(options({ messages: [durable] }), vision, expected.messages, expected.versions, () => undefined)) - expect(actual.messages[0]).toBe(input) - expect(input).not.toHaveProperty('id') - expect(input).not.toHaveProperty('source') - }) - - it('rejects developer history while provider serialization is unsupported', () => { - const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } }) - expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - }) - - it('keeps the original top-level prompt and cached prefix while appending native system updates', () => { - const head = createSystemMessage('original') - const first = [head, user('first')] - const update = createSystemMessage('updated') - const second = [...first, assistant([{ type: 'text', text: 'one' }]), update, user('second')] - const saved = JSON.stringify(second) - const before = nativeBody(first) - const after = nativeBody(second) - expect(after.system).toBe(before.system) - expect(after.messages.slice(0, before.messages.length)).toEqual(before.messages) - expect(after.messages.slice(-2)).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'second' }] }, - { role: 'system', content: [{ type: 'text', text: 'updated' }] }, - ]) - const third = nativeBody([...second, assistant([{ type: 'text', text: 'two' }]), user('third')]) - expect(third.messages.slice(0, after.messages.length)).toEqual(after.messages) - expect(JSON.stringify(second)).toBe(saved) - }) - - it('places system updates after all parallel tool results and before the next assistant', () => { - const history = [createSystemMessage('original'), user(), assistant([call(), call('b')]), - createSystemMessage('first update'), result(), createSystemMessage('second update'), result('b'), - user('more input'), assistant([{ type: 'text', text: 'done' }])] - const request = nativeBody(history) - expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'system', 'assistant']) - expect(request.messages[2]?.content.map(block => block.type)).toEqual(['tool_result', 'tool_result', 'text']) - expect(request.messages.slice(3, 5).map(message => message.content)).toEqual([ - [{ type: 'text', text: 'first update' }], [{ type: 'text', text: 'second update' }], - ]) - }) - - it('accepts native trailing updates without a top-level prompt and rejects unrepresentable positions', () => { - const update = createSystemMessage('update') - expect(nativeBody([user(), update])).toMatchObject({ messages: [ - { role: 'user' }, { role: 'system', content: [{ type: 'text', text: 'update' }] }, - ] }) - expect(nativeBody([user(), update]).system).toBeUndefined() - expect(() => nativeBody([user(), assistant([{ type: 'text', text: 'done' }]), update])).toThrow(/preceding user/) - expect(() => nativeBody([user(), createSystemMessage('')])).toThrow(/empty in-history/) - expect(() => nativeBody([user(), assistant([call(), call('b')]), update, result()])).toThrow(/immediate results/) - }) - - it.each([[], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')]] satisfies ContentBlock[][])( - 'rejects native system updates when their user input is omitted %#', (...content) => { - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) - const history = [user(), assistant([{ type: 'text', text: 'answer' }]), createSystemMessage('updated'), empty] - const saved = JSON.stringify(history) - for (const messages of [history, [...history, assistant([{ type: 'text', text: 'next answer' }])]]) { - expect(() => nativeBody(messages)).toThrow(expect.objectContaining({ - code: 'UNSUPPORTED_CONTENT', - message: 'DeepSeek Messages cannot represent system update without a preceding user or tool-result turn', - })) - } - expect(JSON.stringify(history)).toBe(saved) - }, - ) - - it('keeps native system updates after retained text or tool results beside omitted user input', () => { - const reasoning: ContentBlock = { type: 'reasoning', text: 'child reasoning' } - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content: [reasoning] }) - const update = createSystemMessage('updated') - for (const [previous, retained, expected] of [ - [assistant([{ type: 'text', text: 'answer' }]), user('continue'), [{ type: 'text', text: 'continue' }]], - [assistant([call()]), result('a', [reasoning]), [{ type: 'tool_result', tool_use_id: 'a', content: [], is_error: false }]], - ] as const) { - const history = [user(), previous, update, empty, retained, assistant([{ type: 'text', text: 'done' }])] - const saved = JSON.stringify(history) - const request = nativeBody(history) - expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'assistant']) - expect(request.messages[2]?.content).toEqual(expected) - expect(request.messages[3]?.content).toEqual([{ type: 'text', text: 'updated' }]) - expect(JSON.stringify(history)).toBe(saved) - } - }) - - it('groups parallel results before ordinary text and keeps tool failure content', () => { - const messages = [user(), assistant([call(), call('b')]), user('follow-up'), result(), createToolResultMessage({ callId: ToolCallId('b'), content: [{ type: 'text', text: 'permission denied' }], isError: true })] - expect(body(messages).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: ['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })) }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'result' }], is_error: false }, - { type: 'tool_result', tool_use_id: 'b', content: [{ type: 'text', text: 'permission denied' }], is_error: true }, - { type: 'text', text: 'follow-up' }, - ] }, - ]) - expect(messages[2]?.content).toEqual([{ type: 'text', text: 'follow-up' }]) - }) - - it('preserves empty results without inventing model-visible output', () => { - const response = body([user(), assistant([call()]), result('a', [])]) - expect(response.messages[2]?.content[0]).toMatchObject({ content: [] }) - const { isError: _isError, ...minimal } = createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: '' }], isError: false }) - expect(body([assistant([call()]), minimal]).messages[1]?.content[0]).toEqual({ type: 'tool_result', tool_use_id: 'a', content: [] }) - }) - - it('omits assistant-only blocks from user input while preserving text and durable content', () => { - const history = [createMessage({ role: 'user', source: { kind: 'user' }, content: [ - { type: 'text', text: 'Background subagent finished.\n' }, - { type: 'reasoning', text: 'child reasoning' }, - call('child-call'), - { type: 'text', text: ' child answer ' }, - ] })] - const saved = JSON.stringify(history) - - expect(body(history).messages).toEqual([{ role: 'user', content: [ - { type: 'text', text: 'Background subagent finished.\n' }, - { type: 'text', text: ' child answer ' }, - ] }]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it('omits assistant-only blocks inside tool results while retaining calls, errors and empty results', () => { - const reasoning: ContentBlock = { type: 'reasoning', text: 'tool reasoning' } - const history = [assistant([reasoning, call(), call('b')]), - createToolResultMessage({ callId: ToolCallId('a'), content: [reasoning, call('nested-call'), { type: 'text', text: ' result\n' }], isError: true }), - result('b', [reasoning, call('another-nested-call')]), - ] - const saved = JSON.stringify(history) - - expect(body(history).messages).toEqual([ - { role: 'assistant', content: [ - { type: 'thinking', thinking: 'tool reasoning' }, - ...['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })), - ] }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: ' result\n' }], is_error: true }, - { type: 'tool_result', tool_use_id: 'b', content: [], is_error: false }, - ] }, - ]) - expect(JSON.stringify(history)).toBe(saved) - }) - - it.each([ - [], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')], - ] satisfies ContentBlock[][])('omits empty user input after conversion %#', (...content) => { - const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) - expect(body([empty, user(), assistant([{ type: 'text', text: 'answer' }]), empty]).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, - ]) - }) - - it('collects leading system text and maps tools, stop sequences and explicit output cap', () => { - const system = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'instructions' }] }) - expect(body([system, user()], { system: 'top', maxTokens: 123, stop: ['END'], tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }] })).toMatchObject({ - system: 'top\n\ninstructions', max_tokens: 123, stop_sequences: ['END'], tools: [{ name: 'read', description: 'Read a file', input_schema: { type: 'object' } }], - }) - expect(body([user(), system]).system).toBe('instructions') - }) - - it('uses the latest complete system snapshot without changing tool history or durable messages', () => { - const conversation = [user(), assistant([call()]), result(), assistant([{ type: 'text', text: 'done' }]), user('continue')] - const history = [createSystemMessage('obsolete'), ...conversation.slice(0, 2), - createSystemMessage('intermediate'), ...conversation.slice(2, 4), - createSystemMessage('current'), conversation[4]!] - const saved = JSON.stringify(history) - expect(body(history)).toEqual({ ...body(conversation), system: 'current' }) - expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix\n\ncurrent') - expect(JSON.stringify(history)).toBe(saved) - }) - - it('replaces adjacent system snapshots and joins blocks only within the current snapshot', () => { - const latest = createMessage({ role: 'system', source: { kind: 'system-prompt' }, - content: [{ type: 'text', text: 'part one' }, { type: 'text', text: ' and part two' }] }) - expect(body([createSystemMessage('old'), latest, user()]).system).toBe('part one and part two') - }) - - it.each([[], [{ type: 'text' as const, text: '' }]].map(content => ({ content })))('clears earlier prompt snapshots with empty content %#', ({ content }) => { - const cleared = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content }) - const history = [createSystemMessage('old'), user(), cleared] - expect(body(history).system).toBeUndefined() - expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix') - expect(body(history, { system: '' }).system).toBeUndefined() - }) - - it('rejects non-text system content even when a later snapshot supersedes it', () => { - const invalid = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'reasoning', text: 'bad' }] }) - expect(() => body([invalid, user(), createSystemMessage('current')])).toThrow(/non-text system/) - }) - - it.each(['off', 'low', 'high', 'max'])('maps reasoning effort %s', (effort) => { - const request = body([user()], { reasoningEffort: ReasoningEffortId(effort) }) - expect(request.thinking.type).toBe(effort === 'off' ? 'disabled' : 'enabled') - expect(request.output_config).toEqual(effort === 'off' ? undefined : { effort }) - }) - - it('disables thinking for titles, passes temperature with thinking and refuses unsupported effort', () => { - expect(body([user()], { purpose: 'session-title', temperature: 0 })).toMatchObject({ thinking: { type: 'disabled' }, temperature: 0 }) - expect(body([user()], { temperature: 0 })).toMatchObject({ thinking: { type: 'enabled' }, temperature: 0 }) - expect(() => body([user()], { reasoningEffort: ReasoningEffortId('medium') })).toThrow(/effort/) - const disabled = resolveAdapterOptions({ thinking: 'disabled' }) - expect(serialize(options(), disabled, [user()], new Map(), () => undefined).thinking).toEqual({ type: 'disabled' }) - expect(() => serialize(options({ reasoningEffort: ReasoningEffortId('high') }), disabled, [user()], new Map(), () => undefined)).toThrow(/effort/) - const capped = resolveAdapterOptions({ models: [{ id: MODEL, maxTokens: 321 }] }) - expect(serialize(options(), capped, [user()], new Map(), () => undefined).max_tokens).toBe(321) - }) - - it.each([ - [result()], [assistant([call()])], [assistant([call()]), user()], - [assistant([call(), call()]), result()], - [assistant([call()]), result(), result()], - ])('rejects unmatched or duplicated tool history %#', (...messages) => { - expect(() => body(messages)).toThrow(/tool/) - }) - - it.each(['{', '', '[]', 'null', '42', 'true', '"text"', '{"description":"最快,但"某个说法"没有证据。"}'])('uses empty input for malformed or non-object historical tool arguments %s', (arguments_) => { - const message = assistant([{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: arguments_ }]) - const history = [user(), message, createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: 'Invalid arguments' }], isError: true }), user('Continue')] - const saved = JSON.stringify(history) - const restored = JSON.parse(saved) as Message[] - expect(body(restored).messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 'a', name: 'read', input: {} }] }, - { role: 'user', content: [ - { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'Invalid arguments' }], is_error: true }, - { type: 'text', text: 'Continue' }, - ] }, - ]) - expect(JSON.stringify(restored)).toBe(saved) - }) - - it('preserves own signed thinking, omits absent signatures and validates durable metadata', () => { - const content: ContentBlock[] = [{ type: 'reasoning', text: '' }, { type: 'text', text: 'answer' }] - const source = { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: 'signed' }, { type: 'text' }]) } - const message = createAssistantMessage({ content, source }) - expect(body([user(), message, user()]).messages[1]?.content).toEqual([{ type: 'thinking', thinking: '', signature: 'signed' }, { type: 'text', text: 'answer' }]) - expect(body([assistant([{ type: 'reasoning', text: 'foreign thought' }])]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'foreign thought' }]) - expect(readReplay(message, 'different-model')).toBeUndefined() - expect(readReplay(user(), MODEL)).toBeUndefined() - }) - - it.each([ - null, - [], - { response: null, blocks: [] }, - { response: { kind: 'other', version: 1 }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: 'wrong' }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: null }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [null] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'tool-call' }] }, - { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'reasoning', signature: 3 }] }, - ].map(state => ({ state })))('degrades unusable replay state with a diagnostic %#', ({ state }) => { - const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { provider: 'deepseek-official', model: MODEL, replayState: state } }) - const onDegrade = vi.fn() - expect(readReplay(message, MODEL, onDegrade)).toBeUndefined() - expect(onDegrade).toHaveBeenCalledExactlyOnceWith(expect.any(String)) - expect(body([message]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'think' }]) - }) - - it.each([MODEL, 'different-model'])('keeps durable content when replay degrades for %s', async (model) => { - const message = createAssistantMessage({ - content: [{ type: 'reasoning', text: 'Read the file.' }, { type: 'text', text: 'Checking a.' }, call()], - source: { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [ - { type: 'reasoning', signature: 'do-not-send' }, { type: 'text', signature: 'invalid-for-text' }, { type: 'tool-call' }, - ]) }, - }) - const saved = JSON.stringify(message) - const restored = JSON.parse(saved) as Message - const messages = [user(), restored, result()] - const onDegrade = vi.fn() - const request = serialize(options({ model }), connection, messages, new Map(), () => undefined, onDegrade) - expect(onDegrade).toHaveBeenCalledExactlyOnceWith('DeepSeek Messages replay: invalid signature') - await expect(JSON.stringify(request.messages, null, 2) + '\n').toMatchFileSnapshot('expected/degraded-replay.json') - expect(JSON.stringify(restored)).toBe(saved) - }) - - it('keeps valid cross-model and foreign history quiet and propagates diagnostic failures', () => { - const onDegrade = vi.fn() - const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: '' }]), - } }) - expect(readReplay(message, MODEL, onDegrade)).toEqual([{ type: 'reasoning', signature: '' }]) - expect(readReplay(message, 'different-model', onDegrade)).toBeUndefined() - expect(readReplay(assistant([{ type: 'text', text: 'foreign' }]), MODEL, onDegrade)).toBeUndefined() - expect(onDegrade).not.toHaveBeenCalled() - const damaged = { ...message, source: { ...message.source, replayState: { response: {}, blocks: [] } } } - const failure = new Error('diagnostic failed') - expect(() => readReplay(damaged, MODEL, () => { throw failure })).toThrow(failure) - }) - - it.each([1, 2])('uses empty historical tool input with replay version %s', (version) => { - const message = createAssistantMessage({ content: [{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: '{' }], source: { - provider: 'deepseek-official', model: MODEL, replayState: { response: { kind: 'deepseek-messages', version, model: MODEL }, blocks: [{ type: 'tool-call' }] }, - } }) - const saved = JSON.stringify(message) - const onDegrade = vi.fn() - const request = serialize(options(), connection, [message, result()], new Map(), () => undefined, onDegrade) - expect(request.messages[0]?.content).toEqual([{ type: 'tool_use', id: 'a', name: 'read', input: {} }]) - expect(onDegrade).toHaveBeenCalledTimes(version === 1 ? 0 : 1) - expect(JSON.stringify(message)).toBe(saved) - }) -}) - -describe('validated configuration', () => { - it('advertises exact model metadata and allows unlisted text models', () => { - expect(modelInfo(connection, 'deepseek-official', MODEL)).toMatchObject({ context: { contextWindow: 1_000_000 }, defaultMaxTokens: 256_000, reasoning: { defaultEffort: 'high' } }) - expect(modelInfo(connection, 'deepseek-official', 'custom').inputModalities).toEqual(['text']) - expect(modelInfo(connection, 'deepseek-official', MODEL).systemPromptUpdate).toBeUndefined() - expect(modelInfo(connection, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() - expect(modelInfo(capable, 'deepseek-official', MODEL).systemPromptUpdate).toBe('in-history') - expect(modelInfo(capable, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() - expect(modelInfo(resolveAdapterOptions({ thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }]) - expect(resolveAdapterOptions({ baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///') - }) - it.each([ - { thinking: 'disabled', reasoningEffort: 'high' }, { models: [{ id: '' }] }, - { models: [{ id: 'a' }, { id: 'a' }] }, { models: [{ id: 'a', name: '' }] }, - { maxInlineRequestImageBytes: 1 }, { maxImagesPerRequest: 1 }, - { baseURL: 'ftp://example.com' }, { baseURL: 'https://user:pass@example.com' }, - { baseURL: 'https://example.com/?key=x' }, { baseURL: 'https://example.com/#x' }, - { maxTokens: 0 }, { streamIdleTimeoutMs: 0 }, - { models: [{ id: MODEL, systemPromptUpdate: 'unsupported' }] }, - ])('rejects invalid composition input %#', (value) => { - expect(() => resolveAdapterOptions(value as Config)).toThrow() - }) -}) - -describe('Messages images', () => { - const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', width: 1, height: 1, bytes: 3 } - const image: ImageBlock = { type: 'image', attachment: ref } - const version: RequestImageAttachment = { attachment: ref, variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 3, data: Uint8Array.of(1, 2, 3), width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: false } - const access = () => ({ readonlyPath: '/workspace/image.png' }) - const model = 'deepseek-flash' - // Only the read operation is consumed by image preparation; the transport is mocked, not durable content. - const attachments = { readImageRequest: async () => version } as unknown as AttachmentStore - const signal = new AbortController().signal - it('keeps image bytes inside tool results and deduplicates normalization', async () => { - const history = [assistant([call()]), result('a', [image, image])] - const prepared = await prepareImages(history, connection, model, attachments, access, signal) - expect(prepared.versions.size).toBe(1) - const request = serialize(options({ model }), connection, prepared.messages, prepared.versions, access) - expect(request.messages[1]?.content[0]).toMatchObject({ type: 'tool_result', content: [ - { type: 'text', text: expect.stringContaining('/workspace/image.png') as string }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, - { type: 'text' }, { type: 'image' }, - ] }) - expect(imagePricing(connection, model, access).priceImages([image])[0]?.visualTokens).toBeGreaterThan(0) - expect(imagePricing(connection, MODEL, access).priceImages([image])[0]?.visualTokens).toBe(0) - }) - it('requires logged offload at exact encoded bytes and preserves durable references', async () => { - const config = resolveAdapterOptions({ - maxInlineRequestImageBytes: 4, inlineImageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1, - }) - const history = [result('a', [image, image])] - const prepared = await prepareImages(history, config, model, attachments, access, signal) - expect(prepared.messages[0]?.content).toMatchObject([image, image]) - expect(() => inlineImages(prepared.messages, prepared.versions, config)).toThrow(expect.objectContaining({ - failure: expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) as unknown, - })) - const offloaded: ImageBlock = { ...image, offloaded: true } - const retry = await prepareImages([result('a', [offloaded, image])], config, model, attachments, access, signal) - expect(inlineImages(retry.messages, retry.versions, config)[0]?.content).toMatchObject([{ type: 'text' }, { type: 'image' }]) - expect(history[0]?.content).toMatchObject([image, image]) - expect(imagePricing(config, model, access).priceImages([image, image]).map(entry => entry.visualTokens)) - .toEqual([expect.any(Number), expect.any(Number)]) - const large = { readImageRequest: async () => ({ ...version, bytes: 30, data: new Uint8Array(30) }) } as unknown as AttachmentStore - const exact = await prepareImages([result('a', [image])], config, model, large, access, signal) - expect(() => inlineImages(exact.messages, exact.versions, config)).toThrow(expect.objectContaining({ - failure: expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) as unknown, - })) - }) - it('rejects unsupported roles and unavailable image capabilities before HTTP', async () => { - const history = [result('a', [image])] - await expect(prepareImages(history, connection, MODEL, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - await expect(prepareImages(history, connection, model, undefined, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - await expect(prepareImages([assistant([image])], connection, model, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(() => body([result('a', [image])])).toThrow(/image/) - expect(() => body([assistant([image])])).toThrow(/assistant/) - // @ts-expect-error -- malformed provider input can carry a retired nested result block. - expect(() => body([result('a', [{ type: 'tool-result', toolCallId: ToolCallId('nested'), content: [] }])])) - .toThrow(/user\/tool-result content tool-result/) - expect(() => serialize(options({ model }), connection, [result('a', [image])], new Map([[ref.attachmentId, version]]), access, undefined, new Map())) - .toThrow(/request file id is missing/) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/mock-server.ts b/packages/llm/llm-deepseek/tests/mock-server.ts index 819a945e93..45402bdc1b 100644 --- a/packages/llm/llm-deepseek/tests/mock-server.ts +++ b/packages/llm/llm-deepseek/tests/mock-server.ts @@ -13,7 +13,7 @@ export interface MockServer { requests: unknown[] /** Header bags of received requests, in order (parallel to `requests`). */ headers: IncomingMessage['headers'][] - /** Parsed Files API operations, excluded from chat request ordering. */ + /** Parsed Files API operations, excluded from model request ordering. */ fileRequests: Array<{ method: string; path: string; filename?: string; bytes?: number }> script: Behavior[] close(): Promise @@ -28,18 +28,20 @@ export async function closeMockServers(): Promise { /** A minimal complete text generation, reused by request-shape assertions. */ export const textEvents = [ - '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', - '{"choices":[{"delta":{"content":"hello"}}]}', - '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - '[DONE]', -] + { type: 'message_start', message: { id: 'msg_1', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, +].map(event => JSON.stringify(event)) -/** Local chat-completions stand-in: replays scripted behaviors per request. */ +/** Local Messages stand-in: replays scripted behaviors per request. */ export async function mockServer(script: Behavior[]): Promise { const requests: unknown[] = [] const headers: IncomingMessage['headers'][] = [] const fileRequests: MockServer['fileRequests'] = [] - const files = new Map() + const files = new Map() let nextFile = 1 const server = createServer((request: IncomingMessage, response: ServerResponse) => { const chunks: Buffer[] = [] @@ -48,12 +50,12 @@ export async function mockServer(script: Behavior[]): Promise { void (async () => { const url = new URL(request.url ?? '/', 'http://localhost') const body = Buffer.concat(chunks) - if (url.pathname === '/files' && request.method === 'POST') { + if (url.pathname === '/v1/files' && request.method === 'POST') { const headers = new Headers() for (const [name, value] of Object.entries(request.headers)) { if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value) } - const form = await new Request('http://localhost/files', { + const form = await new Request('http://localhost/v1/files', { method: 'POST', headers, body, @@ -62,28 +64,25 @@ export async function mockServer(script: Behavior[]): Promise { if (!(blob instanceof Blob)) throw new Error('mock upload omitted file') const name = 'name' in blob && typeof blob.name === 'string' ? blob.name : 'uploaded_file' const id = `file-api-${nextFile}` - const createdAt = Math.floor(Date.now() / 1_000) + const createdAt = new Date().toISOString() nextFile += 1 - const expiresSeconds = Number(form.get('expires_after[seconds]')) const file = { id, - object: 'file' as const, - bytes: blob.size, + type: 'file' as const, + size_bytes: blob.size, created_at: createdAt, filename: name, - purpose: 'user_data' as const, - expires_at: createdAt + expiresSeconds, + mime_type: blob.type, } files.set(id, file) fileRequests.push({ method: 'POST', path: url.pathname, filename: name, bytes: blob.size }) response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(file)) return } - if (url.pathname === '/files' && request.method === 'GET') { + if (url.pathname === '/v1/files' && request.method === 'GET') { fileRequests.push({ method: 'GET', path: `${url.pathname}${url.search}` }) const data = [...files.values()] response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ - object: 'list', data, first_id: data[0]?.id, last_id: data.at(-1)?.id, @@ -91,17 +90,17 @@ export async function mockServer(script: Behavior[]): Promise { })) return } - if (url.pathname.startsWith('/files/') && request.method === 'DELETE') { - const id = decodeURIComponent(url.pathname.slice('/files/'.length)) + if (url.pathname.startsWith('/v1/files/') && request.method === 'DELETE') { + const id = decodeURIComponent(url.pathname.slice('/v1/files/'.length)) files.delete(id) fileRequests.push({ method: 'DELETE', path: url.pathname }) response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ - id, object: 'file', deleted: true, + id, type: 'file_deleted', })) return } - if (url.pathname.startsWith('/files/') && request.method === 'GET') { - const id = decodeURIComponent(url.pathname.slice('/files/'.length)) + if (url.pathname.startsWith('/v1/files/') && request.method === 'GET') { + const id = decodeURIComponent(url.pathname.slice('/v1/files/'.length)) fileRequests.push({ method: 'GET', path: url.pathname }) const file = files.get(id) if (file === undefined) { diff --git a/packages/llm/llm-deepseek/tests/prepared-call.spec.ts b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts new file mode 100644 index 0000000000..68e38db8ee --- /dev/null +++ b/packages/llm/llm-deepseek/tests/prepared-call.spec.ts @@ -0,0 +1,58 @@ +/** Prepared calls retain their endpoint and credential generation. */ +import { afterEach, expect, it } from 'vitest' +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { Config, DeepSeekAdapter, resolveAdapterOptions } from '../src/index.ts' +import type { DeepSeekConnectionOptions } from '../src/index.ts' +import { assemble, chunks, MODEL, options, server } from './helpers.ts' + +const close: (() => Promise)[] = [] +afterEach(async () => { + while (close.length) await close.pop()!() +}) +async function endpoint(...args: Parameters) { + const instance = await server(...args) + close.push(() => instance.close()) + return instance +} +function adapter(connection: () => DeepSeekConnectionOptions) { + return new DeepSeekAdapter({ + options: connection, + resolveApiKey: snapshot => Promise.resolve(`key-for-${snapshot.apiKeyEnv}`), + resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId, + prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), + }) +} + +it.each([false, true])('uses Messages, schema=%s', async (schema) => { + const http = await endpoint() + const raw = { baseURL: http.url } + const connection = resolveAdapterOptions(schema ? Config(raw) : raw) + const response = await assemble(adapter(() => connection).stream(options())) + + expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) + expect(http.requests).toHaveLength(1) + expect(http.requests[0]).toMatchObject({ + path: '/anthropic/v1/messages', + headers: { 'x-api-key': 'key-for-DEEPSEEK_API_KEY', 'anthropic-version': '2023-06-01' }, + body: { model: MODEL, messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] }, + }) +}) + +it('keeps the prepared credential reference and endpoint after configuration changes', async () => { + const first = await endpoint(), second = await endpoint() + let connection = resolveAdapterOptions({ baseURL: first.url, apiKeyEnv: 'FIRST_KEY', maxTokens: 12 }) + const llm = adapter(() => connection) + const prepared = await llm.prepareCall('deepseek-official', MODEL) + connection = resolveAdapterOptions({ baseURL: second.url, apiKeyEnv: 'SECOND_KEY', maxTokens: 24 }) + await chunks(prepared.stream(options())) + await chunks(prepared.stream(options())) + expect(prepared.model.defaultMaxTokens).toBe(12) + expect((await llm.resolveModel('deepseek-official', MODEL)).defaultMaxTokens).toBe(24) + await chunks(llm.stream(options())) + expect(first.requests).toHaveLength(2) + for (const request of first.requests) expect(request).toMatchObject({ + path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-FIRST_KEY' }, body: { max_tokens: 12 }, + }) + expect(second.requests).toHaveLength(1) + expect(second.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-SECOND_KEY' } }) +}) diff --git a/packages/llm/llm-deepseek/tests/protocol.spec.ts b/packages/llm/llm-deepseek/tests/protocol.spec.ts deleted file mode 100644 index 788df7982f..0000000000 --- a/packages/llm/llm-deepseek/tests/protocol.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** Same-provider protocol changes retain prepared requests and durable conversation content. */ -import { afterEach, expect, it } from 'vitest' -import type { Message } from '@deepseek-ai/dsh-llm' -import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { Config, DeepSeekAdapter, resolveAdapterOptions } from '../src/index.ts' -import type { DeepSeekConnectionOptions } from '../src/index.ts' -import { assemble, chunks, end, MODEL, options, server, sse, start, textEvents, user } from './messages/helpers.ts' - -const close: (() => Promise)[] = [] -afterEach(async () => { - while (close.length) await close.pop()!() -}) -async function endpoint(...args: Parameters) { - const instance = await server(...args) - close.push(() => instance.close()) - return instance -} -const chat = 'data: {"choices":[{"delta":{"content":"Chat answer"}}]}\n\n' - + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n' -function adapter(connection: () => DeepSeekConnectionOptions) { - return new DeepSeekAdapter({ - options: connection, - resolveApiKey: snapshot => Promise.resolve(`key-for-${snapshot.apiKeyEnv}`), - resolveUserId: () => '00000000-0000-4000-8000-000000000001' as AnonymousUserId, - prepareExtensions: () => Promise.resolve({ fields: {}, accept: () => Promise.resolve() }), - }) -} - -it.each([false, true])('uses Messages when protocol is omitted, schema=%s', async (schema) => { - const http = await endpoint() - const raw = { baseURL: http.url } - const connection = resolveAdapterOptions(schema ? Config(raw) : raw) - const response = await assemble(adapter(() => connection).stream(options())) - - expect(response.message.content).toEqual([{ type: 'text', text: 'Hello 世界' }]) - expect(http.requests).toHaveLength(1) - expect(http.requests[0]).toMatchObject({ - path: '/anthropic/v1/messages', - headers: { 'x-api-key': 'key-for-DEEPSEEK_API_KEY', 'anthropic-version': '2023-06-01' }, - body: { model: MODEL, messages: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }] }, - }) -}) - -it('keeps the prepared Messages protocol, credential reference and endpoint after switching to Chat', async () => { - const first = await endpoint(), second = await endpoint(response => response.end(chat)) - let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: first.url, apiKeyEnv: 'MESSAGES_KEY', maxTokens: 12 }) - const llm = adapter(() => connection) - const prepared = await llm.prepareCall('deepseek-official', MODEL) - connection = resolveAdapterOptions({ protocol: 'chat-completions', baseURL: second.url, apiKeyEnv: 'CHAT_KEY', maxTokens: 24 }) - await chunks(prepared.stream(options())) - await chunks(prepared.stream(options())) - expect(prepared.model.defaultMaxTokens).toBe(12) - expect((await llm.resolveModel('deepseek-official', MODEL)).defaultMaxTokens).toBe(24) - await chunks(llm.stream(options())) - expect(first.requests).toHaveLength(2) - for (const request of first.requests) expect(request).toMatchObject({ - path: '/anthropic/v1/messages', headers: { 'x-api-key': 'key-for-MESSAGES_KEY' }, body: { max_tokens: 12 }, - }) - expect(second.requests).toHaveLength(1) - expect(second.requests[0]).toMatchObject({ path: '/anthropic/chat/completions', headers: { authorization: 'Bearer key-for-CHAT_KEY' } }) -}) - -it('continues Messages → Chat → Messages with the same provider and without leaking native signatures to Chat', async () => { - const signed = [start, - { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'content_block_start', index: 1, content_block: { type: 'text', text: 'Messages answer' } }, - { type: 'content_block_stop', index: 1 }, ...end(), - ] - const http = await endpoint((response, count) => response.end(count === 1 ? sse(signed) : count === 2 ? chat : sse(textEvents))) - let connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url }) - const llm = adapter(() => connection) - const history: Message[] = [user()] - const first = await assemble(llm.stream(options({ messages: history }))) - history.push(first.message, user('continue with Chat')) - const saved = JSON.stringify(history) - connection = resolveAdapterOptions({ protocol: 'chat-completions', baseURL: http.url }) - const second = await assemble(llm.stream(options({ messages: history }))) - expect(JSON.stringify(http.requests[1]?.body)).not.toContain('signature') - expect(http.requests[1]?.body.messages).toContainEqual({ role: 'assistant', content: 'Messages answer', reasoning_content: 'Reasoning' }) - expect(JSON.stringify(history)).toBe(saved) - expect(second.message.source.replayState).toBeUndefined() - history.push(second.message, user('continue with Messages')) - connection = resolveAdapterOptions({ protocol: 'messages', baseURL: http.url }) - const third = await assemble(llm.stream(options({ messages: history }))) - expect(third.assembler.finish.kind).toBe('stop') - const messages = http.requests[2]?.body.messages as { role: string; content: unknown[] }[] - expect(messages.filter(message => message.role === 'assistant')).toEqual([ - { role: 'assistant', content: [{ type: 'thinking', thinking: 'Reasoning', signature: 'native-signature' }, { type: 'text', text: 'Messages answer' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Chat answer' }] }, - ]) -}) diff --git a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts index bf2216d6c8..cb4b31d3c6 100644 --- a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts +++ b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts @@ -3,7 +3,7 @@ import { offloadedImageText, requestImageHandleText, textOnlyImageText } from '@ import type { ImageBlock } from '@deepseek-ai/dsh-llm' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { deepSeekImageRequestPricing } from '../src/common/request-pricing.ts' +import { deepSeekImageRequestPricing } from '../src/request-pricing.ts' import { resolveAdapterOptions } from '../src/index.ts' import type { Config } from '../src/index.ts' diff --git a/packages/llm/llm-deepseek/tests/runtime.e2e.ts b/packages/llm/llm-deepseek/tests/runtime.e2e.ts new file mode 100644 index 0000000000..e2634f8d64 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/runtime.e2e.ts @@ -0,0 +1,462 @@ +import { readFileSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomBytes } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LlmRuntime, { createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage, createSystemMessage } from '@deepseek-ai/dsh-llm' +import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import LocalAttachments from '@deepseek-ai/dsh-attachment-local' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + ImageRequestTarget, + RequestImageAttachment, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek' +import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import type { Config } from '@deepseek-ai/dsh-llm-deepseek' +import type { WireRequest } from '../src/wire-types.ts' +import { assemble, type AssembledResult } from './assemble.ts' + +/** + * Real-API e2e for the direct-fetch adapter: V4 Flash across thinking modes + * and a max-effort tool round trip with reasoning passback. The suite skips + * entirely without $DEEPSEEK_API_KEY; the pre-release vision smoke additionally + * requires $DEEPSEEK_VISION_E2E=1, and the Flash image/system-update smoke + * requires $DEEPSEEK_FLASH_E2E=1. + */ + +const FLASH = 'deepseek-v4-flash' +const VISION = 'deepseek-v4-flash-vision-exp' +const VISION_E2E_ENABLED = process.env.DEEPSEEK_VISION_E2E === '1' +/** A model whose endpoint reads the latest `system` message at any position; unset skips the in-history smoke. */ +const IN_HISTORY_MODEL = process.env.DEEPSEEK_IN_HISTORY_MODEL +const TEST_PNG = Uint8Array.from(readFileSync( + new URL('../../llm-pi-ai/tests/fixtures/qr-code.png', import.meta.url), +)) +const contexts: Context[] = [] +let identityHome: string + +class E2eAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: TEST_PNG.byteLength, + maxImagesPerMessage: 1, + maxMessageImageBytes: TEST_PNG.byteLength, + maxImagePixels: 256 * 256, + maxImageDimension: 256, + mediaTypes: ['image/png'], + } + readonly ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${randomBytes(32).toString('hex')}`), + mediaType: 'image/png', + bytes: TEST_PNG.byteLength, + width: 256, + height: 256, + name: 'files-api-e2e.png', + } + readonly version: RequestImageAttachment = { + variantId: ImageVariantId(`sha256:${randomBytes(32).toString('hex')}`), + attachment: this.ref, + data: TEST_PNG, + mediaType: 'image/png', + bytes: TEST_PNG.byteLength, + width: 256, + height: 256, + depth: 'uchar', + space: 'srgb', + hasAlpha: false, + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(this.ref) + } + + readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref, data: TEST_PNG }) + } + + override readImageRequest( + _ref: ImageAttachmentRef, + _target: ImageRequestTarget, + _signal?: AbortSignal, + ): Promise { + return Promise.resolve(this.version) + } +} + +beforeEach(async () => { + identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-')) + vi.stubEnv('DSH_HOME', identityHome) +}) + +async function harness(model: string, config: Partial = {}) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(E2eAttachmentStore) + await ctx.plugin(LlmDeepSeek, { + baseURL: LlmDeepSeek.PUBLIC_BASE_URL, + ...model === VISION ? { models: [{ id: VISION, inputModalities: ['text', 'image'] }] } : {}, + ...config, + }) + return ctx +} + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + vi.unstubAllGlobals() + vi.unstubAllEnvs() + await rm(identityHome, { recursive: true, force: true }) +}) + +function ask(text: string): Message[] { + return [createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + })] +} + +function textOf(result: AssembledResult): string { + return result.message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +const weatherTool: ToolSchema = { + name: 'get_weather', + description: 'Get the current weather for a city.', + parameters: { + type: 'object', + properties: { city: { type: 'string', description: 'City name' } }, + required: ['city'], + }, +} + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { + it.skipIf(process.env.DEEPSEEK_FLASH_E2E !== '1')('deepseek-flash accepts images and retains system updates', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(LocalAttachments) + await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL, maxTokens: 4096 }) + const model = 'deepseek-flash' + await expect(ctx.llm.resolveModelInfo('deepseek-official', model)).resolves.toMatchObject({ + inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', + }) + const attachment = await ctx.attachments.saveImage({ data: readFileSync(new URL('fixtures/red.png', import.meta.url)), mediaType: 'image/png' }) + const message = ask('What is the dominant color of this image?')[0]! + const history: Message[] = [ + createSystemMessage('Answer with one English color word.'), + { ...message, content: [...message.content, { type: 'image', attachment }] }, + ] + const reply = async () => { + const response = await assemble(ctx, { model, messages: history, reasoningEffort: ReasoningEffortId('high') }) + expect(response.finish.kind, JSON.stringify(response.finish)).toBe('stop') + history.push(response.message) + return textOf(response).trim().toLowerCase() + } + expect(await reply()).toMatch(/^red[.!]?$/) + history.push(createSystemMessage('Reply to every user message with exactly banana.'), ...ask('Answer now.')) + expect(await reply()).toBe('banana') + history.push(...ask('Answer again.')) + expect(await reply()).toBe('banana') + }) + + it.skipIf(!VISION_E2E_ENABLED)('uses the built-in official route to upload, reference, and delete one image', async () => { + const key = process.env.DEEPSEEK_API_KEY + if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') + const baseURL = LlmDeepSeek.PUBLIC_BASE_URL + const ctx = await harness(VISION, { baseURL }) + await ctx.plugin(E2eAttachmentStore) + const attachments = ctx.attachments as E2eAttachmentStore + let uploadedFile: LlmDeepSeek.DeepSeekFileIdType | undefined + const nativeFetch = globalThis.fetch + const observedFetch: typeof fetch = async (input, init) => { + const response = await nativeFetch(input, init) + const url = new URL(input instanceof Request ? input.url : input) + const method = init?.method ?? (input instanceof Request ? input.method : 'GET') + if (method === 'POST' && url.pathname.endsWith('/files') && response.ok) { + const value = await response.clone().json() as { id?: unknown } + if (typeof value.id === 'string') uploadedFile = LlmDeepSeek.DeepSeekFileId(value.id) + } + return response + } + vi.stubGlobal('fetch', observedFetch) + const files = new LlmDeepSeek.DeepSeekFilesClient({ baseURL, apiKey: key }) + + try { + const result = await assemble(ctx, { + model: VISION, + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'Briefly describe this image.' }, + { type: 'image', attachment: attachments.ref }, + ], + source: { kind: 'user' }, + })], + maxTokens: 100, + }) + expect( + result.finish.kind, + `DeepSeek vision result: ${JSON.stringify(result.finish)}`, + ).toBe('stop') + expect(textOf(result).trim().length).toBeGreaterThan(0) + expect(uploadedFile).toMatch(/^file-api-/u) + } finally { + if (uploadedFile !== undefined) await files.delete(uploadedFile) + } + }) + + it('accepts the session-log and plugin-package request extension fields', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + await ctx.plugin(SessionLogDeepSeek, { enabled: true }) + await ctx.plugin(PluginPackageInventoryDeepSeek) + await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL, thinking: 'disabled' }) + const session = ctx.sessions.create(SessionId('real-extension-fields')) + session.append('turn/start', { turn: 1 }) + + const result = await assemble(ctx, { + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + sessionId: session.id, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0) + }) + + 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') + const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-')) + try { + // JSON.stringify quotes the value: YAML is a JSON superset, so a real + // key survives whatever characters it happens to carry. + await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) + // Scrub the ambient variable so only the credential seam can supply the + // key: this request proves the per-request resolution path end to end. + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + await ctx.plugin(LlmDeepSeek, { baseURL: LlmDeepSeek.PUBLIC_BASE_URL }) + + const result = await assemble(ctx, { + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('pong') + } finally { + vi.unstubAllEnvs() + await rm(dir, { recursive: true, force: true }) + } + }) + + it('flash dynamically switches from off to low', async () => { + const ctx = await harness(FLASH, { reasoningEffort: 'off' }) + const withoutThinking = await assemble(ctx,{ + model: FLASH, + messages: ask('Reply with exactly the word: pong'), + maxTokens: 50, + }) + expect(withoutThinking.finish.kind).toBe('stop') + expect(textOf(withoutThinking).toLowerCase()).toContain('pong') + expect(withoutThinking.message.content.some(block => block.type === 'reasoning')).toBe(false) + expect(withoutThinking.usage?.inputTokens).toBeGreaterThan(0) + expect(withoutThinking.usage?.outputTokens).toBeGreaterThan(0) + + const withThinking = await assemble(ctx,{ + model: FLASH, + reasoningEffort: ReasoningEffortId('low'), + messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), + maxTokens: 2000, + }) + expect(withThinking.finish.kind).toBe('stop') + expect(withThinking.message.content.some(block => block.type === 'reasoning' && block.text.trim().length > 0)).toBe(true) + expect(textOf(withThinking)).toContain('9.8') + expect(withThinking.usage?.outputTokens).toBeGreaterThan(0) + }) + + it( + 'flash + thinking enabled (effort max): tool-call round trip with reasoning passback', + async () => { + const ctx = await harness(FLASH, { thinking: 'enabled' }) + + // Turn 1: the model must call the tool (and think before it). + const first = await assemble(ctx,{ + model: FLASH, + reasoningEffort: ReasoningEffortId('max'), + messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), + tools: [weatherTool], + maxTokens: 2000, + }) + expect( + first.finish.kind, + `DeepSeek Flash tool-call turn finished as ${JSON.stringify(first.finish)}`, + ).toBe('tool-calls') + const call = first.message.content.find(block => block.type === 'tool-call') + expect(call).toBeDefined() + expect(call!.name).toBe('get_weather') + expect(JSON.parse(call!.arguments)).toMatchObject({ city: expect.stringMatching(/paris/i) as string }) + + // Turn 2: send the tool result back WITH the assistant's reasoning + // block in history (the official thinking+tools passback rule). + const second = await assemble(ctx,{ + model: FLASH, + reasoningEffort: ReasoningEffortId('max'), + messages: [ + ...ask('What is the weather in Paris right now? Use the get_weather tool.'), + createMessage({ + role: 'assistant', content: first.message.content, + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + createToolResultMessage({ + callId: ToolCallId(call!.id), + content: [{ type: 'text', text: 'Sunny, 22°C' }], + isError: false, + }), + ], + tools: [weatherTool], + maxTokens: 2000, + }) + expect( + second.finish.kind, + `DeepSeek Flash tool-result turn finished as ${JSON.stringify(second.finish)}`, + ).toBe('stop') + expect(textOf(second).toLowerCase()).toMatch(/sunny|22/) + }, + ) + + it.skipIf(IN_HISTORY_MODEL === undefined)( + 'an in-history model follows a mid-history system message and keeps the cached prefix', + async () => { + const model = IN_HISTORY_MODEL as string + const ctx = await harness(model, { + thinking: 'disabled', + models: [{ id: model, systemPromptUpdate: 'in-history' }], + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', model)) + .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) + const system = (text: string) => createSystemMessage(text) + // A nonce before the padding isolates the provider cache across runs and retries. + const nonce = randomBytes(16).toString('hex') + const padding = Array.from({ length: 40 }, (_, index) => `Rule ${String(index + 1)}: keep every answer short and factual.`).join('\n') + // Both update strategies use identical prompt bytes, with the changed instruction near the head. + const prompt = (word: string) => `Test ${nonce}. When the user says ping, reply with exactly the word: ${word}\n${padding}` + const initial = prompt('pong') + const latest = prompt('banana') + const history = [system(initial), ...ask('ping')] + const wireRequests: WireRequest[] = [] + const nativeFetch = globalThis.fetch + const observedFetch: typeof fetch = async (input, init) => { + if (init?.method !== 'POST' || typeof init.body !== 'string') return nativeFetch(input, init) + const body = JSON.parse(init.body) as WireRequest + if (body.model === model) { + wireRequests.push(body) + if (wireRequests.length === 1) { + expect(body.system).toBe(initial) + expect(body.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'ping' }] }]) + } else if (wireRequests.length === 2) { + expect(body).toEqual(wireRequests[0]) + } else if (wireRequests.length === 3) { + expect(body.system).toBe(initial) + expect(body.messages[0]).toEqual(wireRequests[0]!.messages[0]) + expect(body.messages.at(-1)).toEqual({ role: 'system', content: [{ type: 'text', text: latest }] }) + } else if (wireRequests.length === 4) { + expect(body.system).toBe(latest) + expect(body.messages).toEqual(wireRequests[2]!.messages.filter(message => message.role !== 'system')) + } + } + return nativeFetch(input, init) + } + vi.stubGlobal('fetch', observedFetch) + try { + const first = await assemble(ctx, { model, messages: history, maxTokens: 50 }) + expect(first.finish.kind).toBe('stop') + expect(textOf(first).trim()).toBe('pong') + const initialTokens = first.usage?.inputTokens ?? 0 + expect(initialTokens).toBeGreaterThan(0) + + // Measure reusable tokens rather than assuming a provider cache-block size. + const warm = await assemble(ctx, { model, messages: history, maxTokens: 50 }) + expect(warm.finish.kind).toBe('stop') + expect(textOf(warm).trim()).toBe('pong') + const reusableTokens = warm.usage?.cacheReadTokens ?? 0 + expect(reusableTokens).toBeLessThanOrEqual(initialTokens) + expect(reusableTokens).toBeGreaterThan(0) + const assistant = createMessage({ + role: 'assistant', content: first.message.content, source: first.message.source, + }) + + const updated = await assemble(ctx, { + model, + messages: [...history, assistant, system(latest), ...ask('ping')], + maxTokens: 50, + }) + expect(updated.finish.kind).toBe('stop') + expect(textOf(updated).trim()).toBe('banana') + + const replaced = await assemble(ctx, { + model, + messages: [system(latest), ...ask('ping'), assistant, ...ask('ping')], + maxTokens: 50, + }) + expect(replaced.finish.kind).toBe('stop') + expect(textOf(replaced).trim()).toBe('banana') + expect(wireRequests).toHaveLength(4) + + const cached = updated.usage?.cacheReadTokens ?? 0 + expect(replaced.usage?.cacheReadTokens).toBeDefined() + const baselineCached = replaced.usage?.cacheReadTokens ?? 0 + expect(cached).toBeGreaterThanOrEqual(reusableTokens) + expect(cached).toBeGreaterThan(baselineCached) + } finally { + vi.stubGlobal('fetch', nativeFetch) + } + }, + ) + + it('streams raw chunks in protocol order', async () => { + const ctx = await harness(FLASH, { thinking: 'disabled' }) + const kinds: string[] = [] + for await (const chunk of ctx.llm.stream({ + provider: 'deepseek-official', + model: FLASH, + messages: ask('Count from 1 to 5, digits only.'), + maxTokens: 50, + })) { + kinds.push(chunk.type) + } + expect(kinds[0]).toBe('block-start') + expect(kinds.at(-1)).toBe('finish') + expect(kinds.filter(kind => kind === 'finish')).toHaveLength(1) + // usage precedes finish (deferred-emit contract) + expect(kinds.indexOf('usage')).toBeLessThan(kinds.indexOf('finish')) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts new file mode 100644 index 0000000000..55ab3acb4f --- /dev/null +++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts @@ -0,0 +1,2340 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import LlmRuntime, { ToolCallId, createUserMessage, + CONTEXT_WINDOW_EXCEEDED_CODE, + createToolResultMessage, + createAssistantMessage, + LlmError, + ProviderRequestId, + QUOTA_EXCEEDED_CODE, + ReasoningEffortId, + userAgent, +} from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { SessionId } from '@deepseek-ai/dsh-session' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import type { ContextFormed } from '@deepseek-ai/dsh-llm' +import { providerError } from '../src/transport.ts' +import { resolveRequestImageTarget } from '../src/request-pricing.ts' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' +import type { Behavior } from './mock-server.ts' +import { requestImageStore } from './helpers.ts' + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + 'test': { kind: 'test' } & ContextFormed + } +} + +const TEST_USER_ID = '00000000-0000-4000-8000-000000000001' as AnonymousUserId +let testHome: string + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), 'dsh-llm-deepseek-')) + vi.stubEnv('DSH_HOME', testHome) +}) + +afterEach(async () => { + await closeMockServers() + vi.unstubAllEnvs() + vi.useRealTimers() + rmSync(testHome, { recursive: true, force: true }) +}) + +async function harness(baseURL: string, config: object = {}) { + // Configuration carries only the reference; the key comes from the + // environment, which is the whole credential plane without a mounted seam. + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + await ctx.plugin(LlmDeepSeek, { baseURL, ...config }) + return ctx +} + +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function noExtensions(): Promise { + return Promise.resolve({ fields: {}, accept: () => Promise.resolve() }) +} + +/** Direct adapter over the plugin's real resolve step, with a static key. */ +function adapterOf( + config: Partial & { apiKey?: string } = {}, + attachments?: AttachmentStore, + files?: LlmDeepSeek.DeepSeekFileStore, +): DeepSeekAdapter { + const { apiKey, ...rest } = config + return new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ ...rest }), + resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), + resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, + ...files === undefined ? {} : { resolveFiles: () => files }, + prepareExtensions: noExtensions, + }) +} + +async function drain(stream: AsyncIterable): Promise { + for await (const _chunk of stream) { /* drain */ } +} + +const imageRef: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + +function requestImage(ref = imageRef): RequestImageAttachment { + return { + variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), + attachment: ref, + data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + depth: 'uchar', + space: 'srgb', + hasAlpha: true, + } +} + +function attachmentStoreOf( + project: (ref: ImageAttachmentRef, policy: unknown, signal?: AbortSignal) => Promise, +): { + store: AttachmentStore + readImageRequest: ReturnType> +} { + const readImageRequest = vi.fn(project) + return { + store: requestImageStore(readImageRequest), + readImageRequest, + } +} + +function fileStoreOf( + implementation: (...args: Parameters) => ReturnType, +) { + const ensureUploaded = vi.fn(implementation) + const invalidate = vi.fn(() => Promise.resolve()) + return { + store: Object.assign(new LlmDeepSeek.DeepSeekFileStore(), { ensureUploaded, invalidate }), + ensureUploaded, + invalidate, + } +} + +function fileReference(fileId: string): Awaited> { + return { + record: { fileId: LlmDeepSeek.DeepSeekFileId(fileId) }, + uploaded: true, + } as Awaited> +} + +function successfulSseResponse(): Response { + return new Response(textEvents.map(event => `data: ${event}\n\n`).join(''), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }) +} + +describe('request image target', () => { + it.each([ + [ + { id: 'default' }, + { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 }, + ], + [ + { id: 'low', imagePixelBudget: 'low' as const }, + { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 }, + ], + [ + { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, + { width: 565, height: 565, maxBytes: 512_000 }, + ], + ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => { + expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected) + }) + + it('caps every request image at the provider per-side limit', () => { + expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 })) + .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 }) + expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 })) + .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 }) + }) + + it('answers image request pricing from the current connection snapshot', () => { + const adapter = adapterOf({ + models: [{ id: 'vision', inputModalities: ['text', 'image'] }], + }) + const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(priced).toHaveLength(1) + expect(priced?.[0]!.visualTokens).toBeGreaterThan(0) + const textOnly = adapter.imageRequestPricing('deepseek-official', 'unlisted')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(textOnly?.[0]!.visualTokens).toBe(0) + }) + + it('prices descriptor text through the serializer\'s access resolution', () => { + const attachments = {} as AttachmentStore + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ models: [{ id: 'vision', inputModalities: ['text', 'image'] }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, + resolveImageAccess: (store, ref) => (store === attachments && ref === imageRef + ? { readonlyPath: '/world/img.png' } + : undefined), + prepareExtensions: noExtensions, + }) + const priced = adapter.imageRequestPricing('deepseek-official', 'vision')?.priceImages([{ type: 'image', attachment: imageRef }]) + expect(priced?.[0]?.text).toContain('/world/img.png') + }) +}) + +describe('DeepSeekAdapter against a mock server', () => { + it('merges prepared extension fields and accepts them once after HTTP 2xx', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const accept = vi.fn() + const prepareExtensions = vi.fn(async () => ({ + fields: { dsh_test: { version: 1 } }, + accept: async () => { accept() }, + })) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: prepareExtensions as never, + }) + + await drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [], sessionId: SessionId('s') })) + expect(server.requests[0]).toMatchObject({ dsh_test: { version: 1 } }) + expect(prepareExtensions).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 's' })) + expect(accept).toHaveBeenCalledOnce() + }) + + it('fails before fetch on extension preparation or base-field collision', async () => { + const server = await mockServer([]) + const base = { + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + } + const failed = new DeepSeekAdapter({ + ...base, + prepareExtensions: () => Promise.reject(new Error('metadata unavailable')), + }) + await expect(drain(failed.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) + + const collision = new DeepSeekAdapter({ + ...base, + prepareExtensions: (() => Promise.resolve({ fields: { model: 'replacement' }, accept: () => Promise.resolve() })) as never, + }) + await expect(drain(collision.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION' }) + expect(server.requests).toHaveLength(0) + }) + + it('passes cancellation into extension preparation and aborts before fetch', async () => { + const server = await mockServer([]) + const controller = new AbortController() + const started = Promise.withResolvers() + let signalSeen: AbortSignal | undefined + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: ((request: { signal: AbortSignal }) => { + signalSeen = request.signal + started.resolve(undefined) + if (request.signal === undefined) return new Promise(() => {}) + return new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => { + const reason: unknown = request.signal.reason + reject(reason instanceof Error ? reason : new Error('extension preparation aborted', { cause: reason })) + }, { once: true }) + }) + }) as never, + }) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'm', + messages: [], + signal: controller.signal, + })) + await started.promise + expect(signalSeen).toBeInstanceOf(AbortSignal) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + expect(server.requests).toHaveLength(0) + }) + + it('passes cancellation through an outstanding fetch', async () => { + const controller = new AbortController() + const started = Promise.withResolvers() + const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const signal = init?.signal + return new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { + const reason: unknown = signal.reason + reject(reason instanceof Error ? reason : new Error('fetch aborted', { cause: reason })) + }, { once: true }) + }) + }) + try { + const adapter = adapterOf({ baseURL: 'https://provider.invalid' }) + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'm', + messages: [], + signal: controller.signal, + })) + await started.promise + controller.abort() + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + } finally { + fetch.mockRestore() + } + }) + + it('does not accept extensions on non-2xx and does accept before a later stream failure', async () => { + const server = await mockServer([ + { kind: 'http-error', status: 500, body: '{}' }, + { kind: 'close-early', events: textEvents.slice(0, 3) }, + ]) + const accept = vi.fn() + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: () => Promise.resolve({ fields: { dsh_test: 1 }, accept: async () => { accept() } }) as never, + }) + const request = { provider: 'deepseek-official', model: 'm', messages: [] } + + await expect(drain(adapter.stream(request))).rejects.toMatchObject({ code: 'SERVER' }) + expect(accept).not.toHaveBeenCalled() + await expect(drain(adapter.stream(request))).rejects.toBeDefined() + expect(accept).toHaveBeenCalledOnce() + }) + + it('reports a post-2xx extension acceptance failure without relabelling it as transport', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const failure = new Error('watermark append failed') + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: () => Promise.resolve({ + fields: { dsh_test: 1 }, + accept: () => Promise.reject(failure), + }) as never, + }) + + await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] }))) + .rejects.toMatchObject({ code: 'REQUEST_EXTENSION', cause: failure }) + expect(server.requests).toHaveLength(1) + }) + + it('streams a text generation end to end through the assembler', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + const result = await assemble(ctx, { + model: 'deepseek-v4-pro', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1, totalTokens: 4 }) + + // The wire request carried the auth header contents we configured. + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-v4-pro', + max_tokens: 256_000, + output_config: { effort: 'high' }, + stream: true, + }) + // App attribution and DeepSeek request identity are independent wire facts. + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) + expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) + expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-session-id') + expect(server.headers[0]).not.toHaveProperty('http-referer') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') + expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') + expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') + }) + + it('uploads a durable image once and sends only its Files API id to the vision model', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const signalSeen: (AbortSignal | undefined)[] = [] + const policies: unknown[] = [] + const attachmentMocks = attachmentStoreOf((ref, policy, signal) => { + signalSeen.push(signal) + policies.push(policy) + return Promise.resolve(requestImage(ref)) + }) + const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-flash', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image', attachment: imageRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-flash', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe ' }, + { type: 'text', text: expect.stringContaining(`Image ${imageRef.attachmentId}; request preview 1x1px.`) as string }, + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + ], + }], + }) + expect(server.fileRequests).toEqual([{ + method: 'POST', + path: '/v1/files', + filename: `dsh-${'a'.repeat(16)}-${'b'.repeat(8)}.png`, + bytes: 3, + }]) + expect(signalSeen[0]).toBeInstanceOf(AbortSignal) + expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) + }) + + it('rejects unknown plugin content without preparing nested images', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store) + await expect(drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', messages: [ + createUserMessage({ source: { kind: 'user' }, content: [ + { type: 'plugin:message', + message: { role: 'tool', toolCallId: 'foreign', content: [{ type: 'image', attachment: { ...imageRef } }] }, + } as never, + { type: 'text', text: 'context' }, + ] }), + ] }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(attachmentMocks.readImageRequest).not.toHaveBeenCalled() + expect(server.fileRequests).toHaveLength(0) + expect(server.requests).toHaveLength(0) + }) + + it('falls back to one all-base64 request when Files API resolution fails', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + const body = server.requests[0] as { messages: Array<{ content: unknown }> } + expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image"/g)).toHaveLength(2) + expect(JSON.stringify(body)).not.toContain('file_id') + expect(files.ensureUploaded).toHaveBeenCalledTimes(1) + }) + + it('fails a base64 fallback whose retained images exceed the inline bound with the count to offload', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.reject(new LlmError('Files unavailable', 'SERVER'))) + const adapter = adapterOf({ + baseURL: server.url, + maxInlineRequestImageBytes: 80, + inlineImageOffloadByteQuantum: 40, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: imageRef })), + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', message: expect.stringContaining('11 more') as string }) + expect(server.requests).toHaveLength(0) + }) + it('discards partially resolved file ids and falls back with every retained image inline', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) } + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const files = fileStoreOf(() => Promise.reject(new Error('unused'))) + files.ensureUploaded + .mockResolvedValueOnce(fileReference('file-api-partial')) + .mockRejectedValueOnce(new LlmError('Files unavailable', 'TRANSPORT')) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + const body = server.requests[0] as { messages: Array<{ content: unknown }> } + expect(JSON.stringify(body.messages[0]?.content).match(/"type":"image"/g)).toHaveLength(2) + expect(JSON.stringify(body)).not.toContain('file-api-partial') + }) + + it('falls back after the configured Files API deadline without aborting the model request', async () => { + vi.useFakeTimers() + const started = Promise.withResolvers() + const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { + const reason: unknown = signal.reason + reject(reason instanceof Error ? reason : new Error('files operation aborted')) + }, { once: true }) + })) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulSseResponse()) + const adapter = adapterOf({ + baseURL: 'https://deepseek.invalid', + filesApiTimeoutMs: 50, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + await started.promise + await vi.advanceTimersByTimeAsync(50) + await pending + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]?.[1]?.body).toEqual(expect.stringContaining('base64')) + fetchSpy.mockRestore() + }) + + it('does not turn caller cancellation during file resolution into base64 fallback', async () => { + const started = Promise.withResolvers() + const files = fileStoreOf((_version, _connection, _policy, signal) => new Promise((_resolve, reject) => { + started.resolve(undefined) + signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) + })) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const fetchSpy = vi.spyOn(globalThis, 'fetch') + const controller = new AbortController() + const adapter = adapterOf({ + baseURL: 'https://deepseek.invalid', + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + const pending = drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + signal: controller.signal, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + await started.promise + controller.abort() + + await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + expect(fetchSpy).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it('does not retry a generic Messages failure through base64 fallback', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'chat unavailable' } }), + }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.resolve(fileReference('file-api-ready'))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'SERVER', message: 'chat unavailable' }) + + expect(server.requests).toHaveLength(1) + expect(JSON.stringify(server.requests[0])).toContain('file-api-ready') + expect(JSON.stringify(server.requests[0])).not.toContain('base64') + }) + + it('does not prepare a surface-offloaded image and sends its placeholder instead', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const old = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), bytes: 3 } + const recent = { ...imageRef, attachmentId: AttachmentId(`sha256:${'d'.repeat(64)}`), bytes: 3 } + const attachmentMocks = attachmentStoreOf((ref) => { + if (ref.attachmentId === old.attachmentId) throw new Error('old image must not be read') + return Promise.resolve(requestImage(ref)) + }) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachmentMocks.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: old, offloaded: true }, + { type: 'image', attachment: recent }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( + recent, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, + expect.any(AbortSignal), + ) + const body = server.requests[0] as { messages: unknown[] } + expect(body.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining(`image omitted to fit request image limits; ${old.attachmentId}`) as string }, + { type: 'text', text: expect.stringContaining(String(recent.attachmentId)) as string }, + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + ], + }) + }) + + it('projects nested tool-result images with route-owned request budgets', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ + baseURL: server.url, + models: [ + { + id: 'vision-low', + inputModalities: ['text', 'image'], + imagePixelBudget: 'low', + imageMaxBytes: 512_000, + }, + { + id: 'vision-custom', + inputModalities: ['text', 'image'], + imagePixelBudget: 320_000, + }, + ], + }, attachmentMocks.store) + const nested = createToolResultMessage({ + callId: ToolCallId('image-result'), + content: [{ type: 'image', attachment: imageRef }], + isError: false, + }) + + const call = createAssistantMessage({ + source: { provider: 'deepseek-official', model: 'vision-low' }, + content: [{ type: 'tool-call', id: nested.toolCallId, name: 'read_image', arguments: '{}' }], + }) + await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-low', messages: [call, nested] })) + await drain(adapter.stream({ provider: 'deepseek-official', model: 'vision-custom', messages: [call, nested] })) + + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( + 1, + imageRef, + { width: 1, height: 1, maxBytes: 512_000 }, + expect.any(AbortSignal), + ) + expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( + 2, + imageRef, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, + expect.any(AbortSignal), + ) + }) + + it('reuses the exact request version between agent and compaction calls', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + const messages = [createUserMessage({ + content: [{ type: 'image' as const, attachment: imageRef }], + source: { kind: 'test' as const }, + })] + + await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-flash-vision-exp', messages })) + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages, + purpose: 'compaction', + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(1) + expect(server.requests).toMatchObject([ + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + ]) + expect(server.headers[1]?.['x-deepseek-harness-compact']).toBe('1') + }) + + it('explains a provider rejection of a normalized image and retains the raw response as cause', async () => { + const raw = JSON.stringify({ error: { message: 'unsupported image payload for file-api-1' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + let failure: unknown + try { + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + } catch (error: unknown) { + failure = error + } + expect(failure).toMatchObject({ + code: 'INVALID_REQUEST', + message: expect.stringContaining( + `normalized image "${imageRef.attachmentId}" at message 1, image 1`, + ) as string, + cause: { message: raw }, + }) + expect((failure as Error).message).toContain('image/png, 8-bit sRGBA, 1x1') + expect((failure as Error).message).toContain('unsupported image payload for file-api-1') + expect((failure as Error).message).not.toBe(raw) + }) + + it('identifies the sole image when a normalized rejection omits its file id', async () => { + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ + message: expect.stringContaining(`normalized image "${imageRef.attachmentId}"`) as string, + }) + }) + + it('lists every candidate when a normalized multi-image rejection names no file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const raw = JSON.stringify({ error: { message: 'unsupported image payload' } }) + const server = await mockServer([{ kind: 'http-error', status: 400, body: raw }]) + const attachments = attachmentStoreOf((ref) => { + const first = ref.attachmentId === imageRef.attachmentId + return Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(first ? 'b' : 'd').repeat(64)}`), + attachment: first ? { ...ref, name: 'diagram.png' } : ref, + hasAlpha: false, + }) + }).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + message: expect.stringContaining('Candidate images: "diagram.png"') as string, + cause: { message: raw }, + }) + }) + + it.each([ + 'file-api-1 expired', + 'file_id file-api-10 invalid; file_id file-api-1 expired', + 'file_id file-api-1 expired', + 'file_not_found', + 'file_id file-api-1 deleted', + 'invalid file_id file-api-1', + ])('reuploads once when Messages rejects a Files API reference as %s', async (providerMessage) => { + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: providerMessage } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachmentMocks.store) + const options = { + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image' as const, attachment: imageRef }], + source: { kind: 'test' as const }, + })], + } + + await drain(adapter.stream(options)) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) + expect(server.requests).toMatchObject([ + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }] }] }, + { messages: [{ content: [expect.objectContaining({ type: 'text' }), { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }] }] }, + ]) + expect(attachmentMocks.readImageRequest).toHaveBeenCalledTimes(1) + }) + + it('uses inline fallback when stale-id recovery cannot resolve a replacement file', async () => { + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file_id file-api-stale expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const files = fileStoreOf(() => Promise.reject(new Error('unused'))) + files.ensureUploaded + .mockResolvedValueOnce(fileReference('file-api-stale')) + .mockRejectedValueOnce(new LlmError('Files unavailable', 'SERVER')) + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments, files.store) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(files.invalidate).toHaveBeenCalledTimes(1) + expect(server.requests).toHaveLength(2) + expect(JSON.stringify(server.requests[0])).toContain('file-api-stale') + expect(JSON.stringify(server.requests[1])).toContain('base64') + }) + + it('invalidates only the identified mapping when a multi-image request names one stale file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file_id file-api-2 expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(3) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }]) + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-3' } }]) + }) + + it('invalidates every listed missing file id and preserves unlisted mappings', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const thirdRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'path.to.object[index]: the following file_ids do not exist or are not created under your account: ' + + 'file-api-1, file-api-3, file-api-unknown', + }, + }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf((ref) => { + let digest = 'f' + if (ref.attachmentId === imageRef.attachmentId) digest = 'b' + else if (ref.attachmentId === secondRef.attachmentId) digest = 'd' + return Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${digest.repeat(64)}`), + }) + }).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + { type: 'image', attachment: thirdRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(5) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[0]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([ + { type: 'image', source: { type: 'file', file_id: 'file-api-1' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-3' } }, + ]) + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([ + { type: 'image', source: { type: 'file', file_id: 'file-api-4' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-2' } }, + { type: 'image', source: { type: 'file', file_id: 'file-api-5' } }, + ]) + }) + + it('invalidates every used mapping when a stale-file response does not identify one file id', async () => { + const secondRef: ImageAttachmentRef = { + ...imageRef, + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + } + const server = await mockServer([ + { + kind: 'http-error', + status: 400, + body: JSON.stringify({ error: { message: 'file reference expired' } }), + }, + { kind: 'sse', events: textEvents }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve({ + ...requestImage(ref), + variantId: ImageVariantId(`sha256:${(ref.attachmentId === imageRef.attachmentId ? 'b' : 'd').repeat(64)}`), + })).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef }, + { type: 'image', attachment: secondRef }, + ], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) + + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(4) + const retries = server.requests as Array<{ messages: Array<{ content: Array<{ type: string; file_id?: string }> }> }> + expect(retries[1]?.messages[0]?.content.filter(block => block.type === 'image')) + .toEqual([{ type: 'image', source: { type: 'file', file_id: 'file-api-3' } }, { type: 'image', source: { type: 'file', file_id: 'file-api-4' } }]) + }) + + it('returns the second stale-file rejection without a third model attempt', async () => { + const stale = JSON.stringify({ error: { message: 'file_id file-api-1 expired' } }) + const server = await mockServer([ + { kind: 'http-error', status: 400, body: stale }, + { kind: 'http-error', status: 400, body: stale }, + ]) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))).store + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'INVALID_REQUEST', message: 'file_id file-api-1 expired' }) + expect(server.requests).toHaveLength(2) + expect(server.fileRequests.filter(request => request.method === 'POST')).toHaveLength(2) + }) + + it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( + 'rejects image input for text-only model %s before credentials, image reads, or fetch', + async (model) => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const attachments = attachmentStoreOf(ref => Promise.resolve(requestImage(ref))) + const resolveAttachments = vi.fn(() => attachments.store) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + resolveAttachments, + prepareExtensions: noExtensions, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(attachments.readImageRequest).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }, + ) + + it('rejects vision input without an attachment provider before credentials or fetch', async () => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + prepareExtensions: noExtensions, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }) + + it('streams raw chunks through ctx.llm.stream', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) + const ctx = await harness(server.url) + + const kinds: string[] = [] + for await (const chunk of ctx.llm.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + })) { + kinds.push(chunk.type) + } + expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + }) + + it('forwards the harness user and session ids for host-side trajectory routing', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + sessionId: SessionId('child-session'), + }) + + expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') + expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId()) + }) + + it('marks the auxiliary compaction call on the wire', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + purpose: 'compaction', + }) + + expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') + }) + + it('switches dynamically from the configured low default through off to max', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'low' }) + + await assemble(ctx,{ + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('off'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi again' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('max'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'one more time' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(server.requests[0]).toMatchObject({ + thinking: { type: 'enabled' }, + output_config: { effort: 'low' }, + }) + expect(server.requests[1]).toMatchObject({ + thinking: { type: 'disabled' }, + }) + expect(server.requests[1]).not.toHaveProperty('output_config') + expect(server.requests[2]).toMatchObject({ + thinking: { type: 'enabled' }, + output_config: { effort: 'max' }, + }) + }) + + it('uses the configured maxTokens default and preserves an explicit request cap', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { maxTokens: 32_000 }) + + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], maxTokens: 8_192 }) + + expect(server.requests[0]).toMatchObject({ max_tokens: 32_000 }) + expect(server.requests[1]).toMatchObject({ max_tokens: 8_192 }) + }) + + it('publishes only off and omits the wire effort when thinking is disabled', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url, { thinking: 'disabled' }) + + await assemble(ctx,{ + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(server.requests[0]).toMatchObject({ + thinking: { type: 'disabled' }, + }) + expect(server.requests[0]).not.toHaveProperty('output_config') + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it('reports a per-request effort failure before I/O when thinking is disabled', async () => { + const server = await mockServer([]) + const ctx = await harness(server.url, { thinking: 'disabled' }) + + const result = await assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('high'), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) + expect(server.requests).toHaveLength(0) + }) + + it.each(['high', 'max'])( + 'rejects direct adapter effort %s before I/O when thinking is disabled', + async (effort) => { + const server = await mockServer([]) + const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' }) + + const stream = adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId(effort), + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })], + }) + await expect(async () => { + for await (const _chunk of stream) { /* drain */ } + }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(0) + }, + ) + + it.each([ + [401, 'AUTH'], + [403, 'AUTH'], + [429, 'RATE_LIMIT'], + [400, 'INVALID_REQUEST'], + [500, 'SERVER'], + [503, 'SERVER'], + ])('maps HTTP %d to failure code %s with the body message', async (status, code) => { + const behavior: Behavior = { + kind: 'http-error', + status, + body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), + } + const server = await mockServer([behavior]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: `failed with ${status}`, code, status }, + }) + }) + + it('retains the HTTP status when an error response has no body', async () => { + const server = await mockServer([{ kind: 'http-error', status: 500, body: '' }]) + const adapter = adapterOf({ baseURL: server.url }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [], + }))).rejects.toMatchObject({ + failure: { code: 'SERVER', status: 500 }, + message: 'DeepSeek Messages request failed (500)', + }) + }) + + it('classifies an HTTP context-window failure with the canonical code', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.', + type: 'invalid_request_error', + code: 'context_length_exceeded', + }, + }), + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, + }) + }) + + it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'slow down' } }), + headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'slow down', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-429'), + }, + }) + }) + + it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => { + const now = 1_800_000_000_000 + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now) + try { + const server = await mockServer([{ + kind: 'http-error', + status: 503, + body: JSON.stringify({ error: { message: 'come back later' } }), + headers: { + 'retry-after': new Date(now + 3_000).toUTCString(), + 'x-deepseek-request-id': 'deepseek-503', + }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'come back later', + code: 'SERVER', + status: 503, + providerRetryAfterMs: 3_000, + requestId: ProviderRequestId('deepseek-503'), + }, + }) + } finally { + dateNow.mockRestore() + } + }) + + it('omits zero, non-finite, invalid, and past Retry-After values', async () => { + const values = [ + '0', + '9'.repeat(400), + 'not-a-date', + new Date(0).toUTCString(), + ] + for (const value of values) { + const server = await mockServer([{ + kind: 'http-error', + status: 429, + body: JSON.stringify({ error: { message: 'retry later' } }), + headers: { 'retry-after': value }, + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 }, + }) + } + }) + + it('recognizes context-capacity details independently of the HTTP status', () => { + expect(providerError({ error: { message: 'request too large for model context' } }, 400).code) + .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + expect(providerError({ error: { message: 'invalid input: temperature exceeds maximum allowed value' } }, 400).code) + .toBe('INVALID_REQUEST') + expect(providerError({ error: { code: 'context_length_exceeded' } }, 413).code).toBe('CONTEXT_WINDOW_EXCEEDED') + }) + + it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { + expect(providerError({ error: { code: 'insufficient_quota', message: 'account credits exhausted' } }, 429).code) + .toBe(QUOTA_EXCEEDED_CODE) + expect(providerError({ error: { message: 'request rate limit exceeded' } }, 429).code).toBe('RATE_LIMIT') + }) + + it('includes the status for JSON error bodies without a message', async () => { + const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toBe('DeepSeek Messages request failed (500)') + }) + + it('includes the status for non-JSON error bodies', async () => { + const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toBe('DeepSeek Messages request failed (502)') + }) + + it('maps unusual statuses to HTTP_', () => { + expect(providerError({}, 418).code).toBe('HTTP_418') + }) + + it('reports a failed endpoint connection as a transport failure', async () => { + // Port 1 is reserved/unbound, so the service normalizes the fetch failure. + const ctx = await harness('http://127.0.0.1:1') + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { + code: 'TRANSPORT', + message: 'DeepSeek Messages transport failed', + }, + }) + }) + + it('classifies an aborted request as an aborted finish', async () => { + const controller = new AbortController() + controller.abort() + const ctx = await harness('http://127.0.0.1:1') + const result = await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [], + signal: controller.signal, + }) + expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } }) + }) + + it('throws EMPTY_RESPONSE when the response has no body', async () => { + const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' }) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 200 }), + ) + try { + const iterate = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(iterate()).rejects.toThrow(/no response body/) + } finally { + fetchSpy.mockRestore() + } + }) + + it('classifies an abrupt body close as TRANSPORT', async () => { + const server = await mockServer([{ + kind: 'close-early', + events: textEvents.slice(0, 3), + }]) + const ctx = await harness(server.url) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('TRANSPORT') + expect(result.finish.failure.message).toBe('DeepSeek Messages transport failed') + }) + + it('aborts mid-stream via the request signal', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }]) + const ctx = await harness(server.url) + const controller = new AbortController() + + const pending = (async () => { + const chunks = [] + for await (const chunk of ctx.llm.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash', + messages: [], + signal: controller.signal, + })) { + chunks.push(chunk) + } + return chunks + })() + + setTimeout(() => { controller.abort() }, 30) + const chunks = await pending + expect(chunks).toHaveLength(1) + expect(chunks[0]?.type).toBe('finish') + if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk') + expect(chunks[0].reason.kind).toBe('aborted') + if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish') + expect(chunks[0].reason.failure.code).toBe('ABORTED') + }) + + it('maps connection failures to TRANSPORT without losing the cause', async () => { + const cause = new TypeError('connection refused') + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('renders a non-Error transport rejection without losing its cause', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const failed = Promise.withResolvers() + failed.reject('offline') + return failed.promise + }) + const adapter = adapterOf({ baseURL: 'https://example.invalid' }) + try { + const drain = async (): Promise => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + } + await expect(drain()).rejects.toMatchObject({ + message: 'DeepSeek Messages transport failed', + code: 'TRANSPORT', + cause: 'offline', + }) + } finally { + fetchSpy.mockRestore() + } + }) + + it('aborts the underlying body when the stream stays idle past its watchdog', async () => { + vi.useFakeTimers() + let stopped = false + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + const signal = init?.signal + const body = new ReadableStream({ + start(controller) { + signal?.addEventListener('abort', () => { + stopped = true + controller.error(signal.reason) + }, { once: true }) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ + baseURL: 'https://example.invalid', + streamIdleTimeoutMs: 100, + }) + try { + const drain = (async () => { + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + })() + const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(stopped).toBe(true) + } finally { + fetchSpy.mockRestore() + } + }) + + it('keeps an idle provider read alive through SSE comments', async () => { + vi.useFakeTimers() + const encoder = new TextEncoder() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) + setTimeout(() => { + controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) + controller.close() + }, 225) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ + baseURL: 'https://example.invalid', + streamIdleTimeoutMs: 100, + }) + try { + const chunks: string[] = [] + const drain = (async () => { + for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { + chunks.push(chunk.type) + } + })() + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await expect(drain).resolves.toBeUndefined() + expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + } finally { + fetchSpy.mockRestore() + } + }) +}) + +describe('plugin registration and config', () => { + it('resolves the Messages endpoint without rewriting overrides', () => { + expect(resolveAdapterOptions({}).baseURL).toBe('https://api.deepseek.com/anthropic') + for (const baseURL of ['https://example.com', 'https://example.com/custom']) { + expect(resolveAdapterOptions({ baseURL }).baseURL).toBe(baseURL) + } + const retired = { protocol: 'messages', baseURL: 'https://gateway.example' } + expect(() => resolveAdapterOptions(retired)).toThrow(/protocol/) + }) + + it('keeps wire helpers off the package root', () => { + for (const helper of [ + 'httpErrorCode', + 'serializeMessages', + 'serializeRequest', + 'DONE', + 'parseSse', + 'mapFinishReason', + 'mapUsage', + 'translate', + ]) expect(LlmDeepSeek).not.toHaveProperty(helper) + }) + + it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { + const server = await mockServer([]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + const fiber = await ctx.plugin(LlmDeepSeek, { + baseURL: server.url, + }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + expect(ctx.llm.listConfigurableProviders()).toEqual([{ + provider: 'deepseek-official', + displayName: 'DeepSeek', + settingsNs: 'llm-deepseek', + settingsPath: [], + }]) + await fiber.dispose() + expect(ctx.llm.listProviders()).toEqual([]) + expect(ctx.llm.listConfigurableProviders()).toEqual([]) + }) + + it('registers retryPolicy from the provider config', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + retryPolicy: { + mode: 'always', + backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 }, + }, + }) + + expect(ctx.llm.providerRetryPolicy('deepseek-official')).toEqual({ + mode: 'always', + initialDelayMs: 25, + maxDelayMs: 100, + jitterRatio: 0.2, + }) + }) + + it('owns the deepseek provider and advertises the default models', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, + ]) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')) + .resolves.toMatchObject({ + provider: 'deepseek-official', + id: 'deepseek-flash', + name: 'DeepSeek-V41-Flash', + inputModalities: ['text', 'image'], + systemPromptUpdate: 'in-history', + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + }) + + it('keeps deepseek-v4-pro available with its V4 capabilities', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + const info = await ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro') + expect(info).toMatchObject({ + id: 'deepseek-v4-pro', + inputModalities: ['text'], + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + }) + expect(info?.systemPromptUpdate).toBeUndefined() + }) + + it.each(['off', 'low', 'max'] as const)('uses the configured %s reasoning default', async (effort) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + reasoningEffort: effort, + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: ReasoningEffortId('low'), name: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: ReasoningEffortId('high'), name: 'High', description: 'The default balance for most tasks.' }, + { id: ReasoningEffortId('max'), name: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, + ], + defaultEffort: ReasoningEffortId(effort), + }, + }) + }) + + it('accepts off as the default when thinking is deployment-disabled', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort: 'off', + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it.each(['low', 'high', 'max'] as const)( + 'rejects configured reasoning effort %s when thinking is disabled', + async (reasoningEffort) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort, + })).rejects.toThrow(/only reasoningEffort "off"/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each(['low', 'high', 'max'] as const)( + 'rejects disabled-thinking effort %s at the resolver boundary', + (reasoningEffort) => { + expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort })) + .toThrow(/only reasoningEffort "off"/) + }, + ) + + it('accepts disabled thinking with off at the resolver boundary', async () => { + const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' }) + await expect(adapter.resolveModel('deepseek-official', 'pass-through')).resolves.toMatchObject({ + reasoning: { + efforts: [{ + id: ReasoningEffortId('off'), + name: 'Off', + description: 'Use for simple tasks that do not need reasoning.', + }], + defaultEffort: ReasoningEffortId('off'), + }, + }) + }) + + it('uses the default model catalog when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, + { + provider: 'deepseek-official', + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + inputModalities: ['text'], + }, + ]) + }) + + it('defaults an adapter-supplied catalog entry to text input', async () => { + const connection = resolveAdapterOptions({ models: [] }) + const adapter = new DeepSeekAdapter({ + options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + prepareExtensions: noExtensions, + }) + await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ + provider: 'deepseek-official', + id: 'adapter-model', + name: 'adapter-model', + inputModalities: ['text'], + }]) + }) + + it('advertises configured models without restricting arbitrary request ids', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [ + { id: 'private-fast', contextWindow: 32_000 }, + { + id: 'private-reasoner', + name: 'Private Reasoner', + description: 'Higher reasoning budget', + contextWindow: 64_000, + inputModalities: ['text', 'image'], + }, + ], + }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ + { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, + ]) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) + .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-reasoner')) + .resolves.toMatchObject({ + name: 'Private Reasoner', + description: 'Higher reasoning budget', + inputModalities: ['text', 'image'], + }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) + .resolves.toMatchObject({ + context: { contextWindow: 1_000_000 }, + defaultMaxTokens: 256_000, + }) + }) + + it('uses exact model capacity before the adapter-wide default', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + defaultContextWindow: 256_000, + models: [ + { id: 'inherits-default' }, + { id: 'exact-override', contextWindow: 64_000 }, + ], + }) + + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'inherits-default')) + .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'exact-override')) + .resolves.toMatchObject({ context: { contextWindow: 64_000 } }) + await expect(ctx.llm.resolveModelInfo('deepseek-official', 'unlisted-pass-through')) + .resolves.toMatchObject({ context: { contextWindow: 256_000 } }) + }) + + it('allows an explicit empty model catalog', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [], + }) + await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) + }) + + const invalidModels: Array<[object[], RegExp]> = [ + [[{ id: '' }], /ids must be non-empty/], + [[{ id: 'm', name: '' }], /empty name/], + [[{ id: 'm', contextWindow: 0 }], /contextWindow/], + [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], + [[{ id: 'm', inputModalities: [] }], /inputModalities/], + [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], + [[{ + id: 'm', + inputModalities: ['audio'], + }], /expected "text" \| "image"/], + [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], + ] + + it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(Reflect.apply(ctx.plugin.bind(ctx), undefined, [LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', models: [...models], + }])).rejects.toThrow(message) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + const invalidProgrammaticModalities: Array<[object[], RegExp]> = [ + [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], + [[{ + id: 'm', + inputModalities: ['audio'], + }], /inputModalities must contain only "text" and "image"/], + ] + + it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { + expect(() => { Reflect.apply(resolveAdapterOptions, undefined, [{ models: [...models] }]) }).toThrow(message) + }) + + it('rejects the removed imageDetail model setting through schema and direct construction', async () => { + const legacyModel = { id: 'vision', inputModalities: ['image' as const], imageDetail: 'low' } + expect(() => resolveAdapterOptions({ models: [legacyModel] })).toThrow(/imageDetail is no longer supported/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + models: [legacyModel], + })).rejects.toThrow(/imageDetail is no longer supported/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { + expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) + .toThrow(/maxTokens must be a positive integer/) + }) + + it('surfaces a catalog model\'s in-history system prompt update mode and rejects any other mode', async () => { + const adapter = adapterOf({ models: [ + { id: 'capable', systemPromptUpdate: 'in-history' }, + { id: 'plain' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capable')) + .resolves.toMatchObject({ systemPromptUpdate: 'in-history' }) + await expect(adapter.resolveModel('deepseek-official', 'plain')) + .resolves.not.toHaveProperty('systemPromptUpdate') + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.not.toHaveProperty('systemPromptUpdate') + expect(() => { Reflect.apply(resolveAdapterOptions, undefined, [{ + models: [{ id: 'bogus', systemPromptUpdate: 'leading' }], + }]) }).toThrow(/systemPromptUpdate must be "in-history" when present/) + }) + + it('rejects image request limits on a text-only catalog model', () => { + expect(() => resolveAdapterOptions({ + models: [{ id: 'text-only', inputModalities: ['text'], imagePixelBudget: 1 }], + })).toThrow(/text-only catalog model .* cannot declare image request limits/) + }) + + it.each([ + ['imagePixelBudget', 0, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', Number.MAX_SAFE_INTEGER + 1, /imagePixelBudget must be "low" or a positive safe integer/], + ['imagePixelBudget', 'auto', /imagePixelBudget must be "low" or a positive safe integer/], + ['imageMaxBytes', 0, /imageMaxBytes must be a positive safe integer/], + ['imageMaxBytes', 1.5, /imageMaxBytes must be a positive safe integer/], + ] as const)('rejects per-model %s=%s', (field, value, message) => { + expect(() => resolveAdapterOptions({ + models: [{ id: 'vision', inputModalities: ['image'], [field]: value }], + })).toThrow(message) + }) + + it('prefers a model\'s own output cap over the profile default', async () => { + // The profile default stays what an unlisted or uncapped model resolves + // to, so adding a per-model cap changes one model rather than the route. + const adapter = adapterOf({ maxTokens: 4096, models: [ + { id: 'capped', maxTokens: 512 }, + { id: 'uncapped' }, + ] }) + await expect(adapter.resolveModel('deepseek-official', 'capped')) + .resolves.toMatchObject({ defaultMaxTokens: 512 }) + await expect(adapter.resolveModel('deepseek-official', 'uncapped')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog')) + .resolves.toMatchObject({ defaultMaxTokens: 4096 }) + }) + + it('rejects invalid context capacity when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + expect(() => { + LlmDeepSeek.apply(ctx, { + baseURL: 'http://127.0.0.1:1', + models: [{ id: 'invalid-context', contextWindow: 0 }], + }) + }).toThrow(/contextWindow must be a positive integer/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it.each([0, 1.5])( + 'rejects invalid adapter-wide default context capacity %s', + async (defaultContextWindow) => { + expect(() => resolveAdapterOptions({ defaultContextWindow })) + .toThrow(/defaultContextWindow must be a positive integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + defaultContextWindow, + })).rejects.toThrow(/defaultContextWindow/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid adapter-wide maxTokens %s', + async (maxTokens) => { + expect(() => resolveAdapterOptions({ maxTokens })) + .toThrow(/maxTokens must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxTokens, + })).rejects.toThrow(/maxTokens/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it('rejects offload quanta larger than their request bounds', () => { + expect(() => resolveAdapterOptions({ + maxRequestFilesBytes: 10, + imageOffloadByteQuantum: 11, + })).toThrow(/imageOffloadByteQuantum must not exceed maxRequestFilesBytes/) + expect(() => resolveAdapterOptions({ + maxInlineRequestImageBytes: 10, + inlineImageOffloadByteQuantum: 11, + })).toThrow(/inlineImageOffloadByteQuantum must not exceed maxInlineRequestImageBytes/) + expect(() => resolveAdapterOptions({ + maxImagesPerRequest: 10, + imageOffloadCountQuantum: 11, + })).toThrow(/imageOffloadCountQuantum must not exceed maxImagesPerRequest/) + }) + + it.each([ + ['maxImagesPerRequest', 0, /maxImagesPerRequest must be a positive safe integer/], + ['maxImagesPerRequest', 1.5, /maxImagesPerRequest must be a positive safe integer/], + ['imageOffloadByteQuantum', 0, /imageOffloadByteQuantum must be a positive safe integer/], + ['imageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /imageOffloadByteQuantum must be a positive safe integer/], + ['inlineImageOffloadByteQuantum', 0, /inlineImageOffloadByteQuantum must be a positive safe integer/], + ['inlineImageOffloadByteQuantum', Number.MAX_SAFE_INTEGER + 1, /inlineImageOffloadByteQuantum must be a positive safe integer/], + ['imageOffloadCountQuantum', 0, /imageOffloadCountQuantum must be a positive safe integer/], + ['imageOffloadCountQuantum', 1.5, /imageOffloadCountQuantum must be a positive safe integer/], + ['fileExpiresAfterSeconds', 3_599, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], + ['fileExpiresAfterSeconds', 2_592_001, /fileExpiresAfterSeconds must be an integer from 3600 through 2592000/], + ['fileRefreshMarginSeconds', -1, /fileRefreshMarginSeconds must be a non-negative integer/], + ['fileRefreshMarginSeconds', 604_800, /fileRefreshMarginSeconds must be a non-negative integer/], + ['fileQuotaCleanupBatch', 0, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], + ['fileQuotaCleanupBatch', 1_001, /fileQuotaCleanupBatch must be an integer from 1 through 1000/], + ] as const)('rejects %s=%s', (field, value, message) => { + expect(() => resolveAdapterOptions({ [field]: value })).toThrow(message) + }) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid request file bound %s', + async (maxRequestFilesBytes) => { + expect(() => resolveAdapterOptions({ maxRequestFilesBytes })) + .toThrow(/maxRequestFilesBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxRequestFilesBytes, + })).rejects.toThrow(/maxRequestFilesBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid inline request image bound %s', + async (maxInlineRequestImageBytes) => { + expect(() => resolveAdapterOptions({ maxInlineRequestImageBytes })) + .toThrow(/maxInlineRequestImageBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxInlineRequestImageBytes, + })).rejects.toThrow(/maxInlineRequestImageBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, {}) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + }) + + it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + // 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) + const first = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + // The guidance leads with the managed credential store. + const second = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(second.finish.kind).toBe('error') + if (second.finish.kind !== 'error') throw new Error('expected an error finish') + // The guidance names both places a credential can come from, and nothing + // else: configuration carries the reference, never a literal key. + expect(second.finish.failure.message) + .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s) + }) + + it('reads the ambient variable when no credentials seam is mounted', async () => { + // The plain cordis.yml composition: no credential provider, the key in + // the launching environment. + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: server.url }) + await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(server.headers[0]?.['x-api-key']).toBe('ambient-key') + }) + + it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + }) + + it('prefers explicit config over env for key and base URL', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') + vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1') + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) // harness passes explicit config + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(server.requests).toHaveLength(1) // hit the explicit URL, not env + }) + + it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + vi.stubEnv('DEEPSEEK_BASE_URL', server.url) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmDeepSeek, {}) + await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(server.requests).toHaveLength(1) + }) + + + it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => { + const trusted = createLaunchEnvironmentSnapshot([ + { source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } }, + ]) + expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example') + // The product trusts the project it is launched in, so a checkout can + // point its own agent at the gateway that checkout is meant to use. + const project = createLaunchEnvironmentSnapshot([ + { source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } }, + ]) + expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example') + // An explicitly configured endpoint outranks every environment layer, so a + // stale shell value cannot rewrite a deployment's own gateway. + const shell = createLaunchEnvironmentSnapshot([ + { source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } }, + ]) + expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal') + }) + it('uses the public Messages base URL without an endpoint override', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'k') + vi.stubEnv('DEEPSEEK_BASE_URL', undefined) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + // Registration succeeds; no call is made (would hit api.deepseek.com). + await ctx.plugin(LlmDeepSeek, {}) + expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) + }) + + it('adapter is constructible directly for embedding over the shared resolver', async () => { + const adapter = adapterOf() + 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) + }) + + it('resolves connection facts and the credential exactly once per stream call', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url })) + const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key')) + const resolveUserId = vi.fn(() => TEST_USER_ID) + const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId, prepareExtensions: noExtensions }) + + for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ } + + expect(options).toHaveBeenCalledTimes(1) + expect(resolveApiKey).toHaveBeenCalledTimes(1) + expect(resolveUserId).toHaveBeenCalledTimes(1) + expect(server.headers[0]?.['x-api-key']).toBe('per-request-key') + }) + + it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => { + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/streamIdleTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/streamIdleTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: 0, + })).rejects.toThrow(/streamIdleTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/streamIdleTimeoutMs/) + }) + + it('validates Files API timeout bounds independently of the stream idle deadline', async () => { + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: Number.POSITIVE_INFINITY })) + .toThrow(/filesApiTimeoutMs.*positive finite/) + expect(() => resolveAdapterOptions({ filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .toThrow(/filesApiTimeoutMs.*no greater/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + filesApiTimeoutMs: 0, + })).rejects.toThrow(/filesApiTimeoutMs/) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + filesApiTimeoutMs: MAX_TIMER_DELAY_MS + 1, + })).rejects.toThrow(/filesApiTimeoutMs/) + expect(resolveAdapterOptions({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 })) + .toMatchObject({ filesApiTimeoutMs: 100, streamIdleTimeoutMs: 100 }) + }) + + it('rejects invalid nested retryPolicy before registering the provider', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + retryPolicy: { mode: 'normal', maxRetries: -1 }, + })).rejects.toThrow(/retryPolicy/) + expect(ctx.llm.listProviders()).toEqual([]) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 804536fe3b..de32f4680f 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,832 +1,458 @@ +/** Request conversion and durable replay validation. */ import { describe, expect, it, vi } from 'vitest' +import { createDeveloperMessage, createUserMessage, createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, ImageBlock, Message, RequestMessage, RequestUserInput } from '@deepseek-ai/dsh-llm' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' -import { createDeveloperMessage, createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message, RequestUserInput } from '@deepseek-ai/dsh-llm' -import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import { - serializeMessages, - serializeMessagesWithImages, - serializeRequest, - serializeRequestWithImages, -} from '../src/protocols/chat-completions/serialize.ts' -import type { ImageSerializationOptions } from '../src/protocols/chat-completions/serialize.ts' +import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import { resolveAdapterOptions } from '../src/config.ts' +import { modelInfo } from '../src/model-info.ts' +import type { Config } from '../src/config.ts' +import { imagePricing, inlineImages, prepareImages } from '../src/images.ts' +import { readReplay, replayState } from '../src/replay.ts' +import { serialize } from '../src/serialize.ts' +import { MODEL, options, user, requestImageStore } from './helpers.ts' -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - 'test': { kind: 'test' } & ContextFormed - } -} +const connection = resolveAdapterOptions({}) +const call = (id = 'a'): ContentBlock => ({ type: 'tool-call', id: ToolCallId(id), name: 'read', arguments: '{"path":"a"}' }) +const assistant = (content: ContentBlock[]) => createAssistantMessage({ content, source: { provider: 'deepseek-official', model: MODEL } }) +const result = (id = 'a', content: ContentBlock[] = [{ type: 'text', text: 'result' }]) => createToolResultMessage({ callId: ToolCallId(id), content, isError: false }) +const body = (messages: RequestMessage[] = [user()], overrides: Partial = {}) => serialize( + options({ messages, ...overrides }), connection, messages, new Map(), () => undefined, +) +const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] }) +const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined) -type FileResolver = Extract['resolveFileId'] - -function request(overrides: Partial = {}): GenerateOptions { - return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides } -} - -function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAttachmentRef { - const digit = ({ - 'image/png': 'a', - 'image/jpeg': 'b', - 'image/webp': 'c', - 'image/gif': 'd', - } as const)[mediaType] - return { - attachmentId: AttachmentId(`sha256:${digit.repeat(64)}`), - mediaType, - bytes, - width: 1, - height: 1, - } -} - -function fileResolver(id = 'file-api-image') { - return vi.fn(() => Promise.resolve(id)) -} - -function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { - const hash = String(ref.attachmentId).slice('sha256:'.length) - return { - variantId: ImageVariantId(`sha256:${hash}`), - attachment: ref, - data: new Uint8Array(ref.bytes), - mediaType: ref.mediaType, - bytes: ref.bytes, - width: ref.width, - height: ref.height, - depth: 'uchar', - space: 'srgb', - hasAlpha: ref.mediaType === 'image/png', - } -} - -function imageOptions( - refs: readonly ImageAttachmentRef[], - resolveFileId: FileResolver = fileResolver(), - maxRequestImageBytes = 20 * 1024 * 1024, -): ImageSerializationOptions { - return { - representation: { kind: 'file' as const, resolveFileId }, - requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), - maxRequestImageBytes, - } -} - -function inlineImageOptions( - refs: readonly ImageAttachmentRef[], - maxRequestImageBytes = 20 * 1024 * 1024, - byteQuantum = 10 * 1024 * 1024, -): ImageSerializationOptions { - return { - representation: { kind: 'base64' }, - requestImages: new Map(refs.map(ref => [ref.attachmentId, requestVersion(ref)])), - maxRequestImageBytes, - byteQuantum, - } -} - -it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { - for (const type of ['tool-addition', 'tool-removal'] as const) { - const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message - expect(() => serializeMessages([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) - } -}) - -it('rejects deferred tool definitions until provider loading is implemented', () => { - expect(() => serializeRequest(request({ tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] }))) - .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) -}) - -describe('request-only user input', () => { - it('preserves the exact text request and durable tool-result prefix', () => { - const prefix = [ - createMessage({ role: 'assistant', content: [{ type: 'tool-call', id: ToolCallId('lookup'), name: 'lookup', arguments: '{}' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' } }), - createToolResultMessage({ callId: ToolCallId('lookup'), content: [{ type: 'text', text: 'result' }], isError: false }), - ] - const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } - const durable = createUserMessage({ content: input.content, source: { kind: 'test' } }) - expect(serializeRequest(request({ messages: [...prefix, input], system: 'policy' }))) - .toEqual(serializeRequest(request({ messages: [...prefix, durable], system: 'policy' }))) +describe('Messages request conversion', () => { + it('rejects unknown plugin content without interpreting its payload', () => { + expect(() => body([createUserMessage({ source: { kind: 'user' }, content: [ + { type: 'plugin:text', text: 'opaque' } as never, + ] })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => { + for (const type of ['tool-addition', 'tool-removal'] as const) { + const content: ContentBlock[] = [{ type, toolName: 'search' }] + const message = role === 'user' ? createUserMessage({ source: { kind: 'user' }, content }) + : role === 'system' ? createMessage({ role, source: { kind: 'system-prompt' }, content }) + : role === 'assistant' ? assistant(content) : result('invalid', content) + expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + } }) - it('preserves image ordering and bytes without manufacturing durable metadata', async () => { - const ref = imageRef() + it('rejects deferred tool definitions until provider loading is implemented', () => { + expect(() => body([], { tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] })) + .toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + + it('preserves the exact request with request-only text after durable tool results', () => { + const prefix = [user(), assistant([call()]), result()] + const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] } + const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) + expect(body([...prefix, input], { system: 'policy' })).toEqual(body([...prefix, durable], { system: 'policy' })) + }) + + it('preserves request-only image content through image preparation and wire conversion', async () => { + const attachment: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'e'.repeat(64)}`), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + } + const version: RequestImageAttachment = { + variantId: ImageVariantId(`sha256:${'f'.repeat(64)}`), attachment, data: Uint8Array.of(1, 2, 3), + mediaType: 'image/png', bytes: 3, width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: true, + } const input: RequestUserInput = { role: 'user', content: [ - { type: 'text', text: 'before' }, { type: 'image', attachment: ref }, { type: 'text', text: 'after' }, + { type: 'text', text: 'before' }, { type: 'image', attachment }, { type: 'text', text: 'after' }, ] } - const durable = createUserMessage({ content: input.content, source: { kind: 'test' } }) - const actual = await serializeRequestWithImages(request({ messages: [input] }), inlineImageOptions([ref])) - const expected = await serializeRequestWithImages(request({ messages: [durable] }), inlineImageOptions([ref])) - expect(actual).toEqual(expected) + const store = requestImageStore(async () => version) + const vision = resolveAdapterOptions({ models: [{ id: MODEL, inputModalities: ['text', 'image'] }] }) + const durable = createUserMessage({ content: input.content, source: { kind: 'user' } }) + const actual = await prepareImages([input], vision, MODEL, store, () => undefined, new AbortController().signal) + const expected = await prepareImages([durable], vision, MODEL, store, () => undefined, new AbortController().signal) + expect(serialize(options({ messages: [input] }), vision, actual.messages, actual.versions, () => undefined)) + .toEqual(serialize(options({ messages: [durable] }), vision, expected.messages, expected.versions, () => undefined)) + expect(actual.messages[0]).toBe(input) expect(input).not.toHaveProperty('id') expect(input).not.toHaveProperty('source') }) -}) -describe('serializeMessages', () => { - it('maps user text to string content', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - expect(wire).toEqual([{ role: 'user', content: 'hello world' }]) + it('rejects developer history while provider serialization is unsupported', () => { + const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } }) + expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) }) - it('maps system-role messages in history', () => { - const wire = serializeMessages([ - createMessage({ - role: 'system', content: [{ type: 'text', text: 'be brief' }], - source: { kind: 'system-prompt' }, - }), + it('keeps the original top-level prompt and cached prefix while appending native system updates', () => { + const head = createSystemMessage('original') + const first = [head, user('first')] + const update = createSystemMessage('updated') + const second = [...first, assistant([{ type: 'text', text: 'one' }]), update, user('second')] + const saved = JSON.stringify(second) + const before = nativeBody(first) + const after = nativeBody(second) + expect(after.system).toBe(before.system) + expect(after.messages.slice(0, before.messages.length)).toEqual(before.messages) + expect(after.messages.slice(-2)).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'second' }] }, + { role: 'system', content: [{ type: 'text', text: 'updated' }] }, ]) - expect(wire).toEqual([{ role: 'system', content: 'be brief' }]) + const third = nativeBody([...second, assistant([{ type: 'text', text: 'two' }]), user('third')]) + expect(third.messages.slice(0, after.messages.length)).toEqual(after.messages) + expect(JSON.stringify(second)).toBe(saved) }) - it('passes reasoning_content back on tool-call-free turns', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'reasoning', text: 'thinking…' }, - { type: 'text', text: 'answer' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - // A gateway that re-encodes the conversation for another vendor recovers - // the upstream thinking signature by hashing this exact text, and a turn - // that called no tool carries it nowhere else. - expect(wire).toEqual([{ role: 'assistant', content: 'answer', reasoning_content: 'thinking…' }]) - }) - - it('passes reasoning_content back on tool-call turns (official passback rule)', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'reasoning', text: 'I should check the weather.' }, - { type: 'tool-call', id: ToolCallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - expect(wire).toEqual([{ - role: 'assistant', - // "" (not null) on tool-call turns — mirrors the official samples' - // verbatim message replay; some gateways reject null. - content: '', - reasoning_content: 'I should check the weather.', - tool_calls: [{ id: 'call-1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' } }], - }]) - }) - - it('serializes parallel tool calls in order', () => { - const wire = serializeMessages([ - createMessage({ - role: 'assistant', - content: [ - { type: 'tool-call', id: ToolCallId('a'), name: 'one', arguments: '{}' }, - { type: 'tool-call', id: ToolCallId('b'), name: 'two', arguments: '{}' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ]) - const assistant = wire[0] as { tool_calls: { id: string }[] } - expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b']) - }) - - it('turns tool messages into role:tool wire messages', () => { - const wire = serializeMessages([ - createToolResultMessage({ - callId: ToolCallId('call-1'), - content: [{ type: 'text', text: 'Sunny 22C' }], - isError: false, - }), - ]) - expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }]) - }) - - it('sends a sentinel for empty tool content', () => { - const wire = serializeMessages([ - createToolResultMessage({ callId: ToolCallId('call-1'), content: [], isError: false }), - ]) - expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }]) - }) - - it('keeps user text and tool messages as separate wire messages', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [{ type: 'text', text: 'context note' }], - source: { kind: 'test' }, - }), - createToolResultMessage({ - callId: ToolCallId('call-1'), - content: [{ type: 'text', text: 'ok' }], - isError: false, - }), - ]) - expect(wire).toEqual([ - { role: 'user', content: 'context note' }, - { role: 'tool', tool_call_id: 'call-1', content: 'ok' }, + it('places system updates after all parallel tool results and before the next assistant', () => { + const history = [createSystemMessage('original'), user(), assistant([call(), call('b')]), + createSystemMessage('first update'), result(), createSystemMessage('second update'), result('b'), + user('more input'), assistant([{ type: 'text', text: 'done' }])] + const request = nativeBody(history) + expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'system', 'assistant']) + expect(request.messages[2]?.content.map(block => block.type)).toEqual(['tool_result', 'tool_result', 'text']) + expect(request.messages.slice(3, 5).map(message => message.content)).toEqual([ + [{ type: 'text', text: 'first update' }], [{ type: 'text', text: 'second update' }], ]) }) - it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => { - const wire = serializeMessages([ - createUserMessage({ - content: [ - { type: 'chart', data: 'x' } as unknown as ContentBlock, - { type: 'text', text: 'see chart' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), + it('accepts native trailing updates without a top-level prompt and rejects unrepresentable positions', () => { + const update = createSystemMessage('update') + expect(nativeBody([user(), update])).toMatchObject({ messages: [ + { role: 'user' }, { role: 'system', content: [{ type: 'text', text: 'update' }] }, + ] }) + expect(nativeBody([user(), update]).system).toBeUndefined() + expect(() => nativeBody([user(), assistant([{ type: 'text', text: 'done' }]), update])).toThrow(/preceding user/) + expect(() => nativeBody([user(), createSystemMessage('')])).toThrow(/empty in-history/) + expect(() => nativeBody([user(), assistant([call(), call('b')]), update, result()])).toThrow(/immediate results/) + }) + + it.each([[], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')]] satisfies ContentBlock[][])( + 'rejects native system updates when their user input is omitted %#', (...content) => { + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) + const history = [user(), assistant([{ type: 'text', text: 'answer' }]), createSystemMessage('updated'), empty] + const saved = JSON.stringify(history) + for (const messages of [history, [...history, assistant([{ type: 'text', text: 'next answer' }])]]) { + expect(() => nativeBody(messages)).toThrow(expect.objectContaining({ + code: 'UNSUPPORTED_CONTENT', + message: 'DeepSeek Messages cannot represent system update without a preceding user or tool-result turn', + })) + } + expect(JSON.stringify(history)).toBe(saved) + }, + ) + + it('keeps native system updates after retained text or tool results beside omitted user input', () => { + const reasoning: ContentBlock = { type: 'reasoning', text: 'child reasoning' } + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content: [reasoning] }) + const update = createSystemMessage('updated') + for (const [previous, retained, expected] of [ + [assistant([{ type: 'text', text: 'answer' }]), user('continue'), [{ type: 'text', text: 'continue' }]], + [assistant([call()]), result('a', [reasoning]), [{ type: 'tool_result', tool_use_id: 'a', content: [], is_error: false }]], + ] as const) { + const history = [user(), previous, update, empty, retained, assistant([{ type: 'text', text: 'done' }])] + const saved = JSON.stringify(history) + const request = nativeBody(history) + expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'assistant']) + expect(request.messages[2]?.content).toEqual(expected) + expect(request.messages[3]?.content).toEqual([{ type: 'text', text: 'updated' }]) + expect(JSON.stringify(history)).toBe(saved) + } + }) + + it('groups parallel results before ordinary text and keeps tool failure content', () => { + const messages = [user(), assistant([call(), call('b')]), user('follow-up'), result(), createToolResultMessage({ callId: ToolCallId('b'), content: [{ type: 'text', text: 'permission denied' }], isError: true })] + expect(body(messages).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: ['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })) }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'result' }], is_error: false }, + { type: 'tool_result', tool_use_id: 'b', content: [{ type: 'text', text: 'permission denied' }], is_error: true }, + { type: 'text', text: 'follow-up' }, + ] }, ]) - expect(wire).toEqual([{ role: 'user', content: 'see chart' }]) + expect(messages[2]?.content).toEqual([{ type: 'text', text: 'follow-up' }]) }) - it('rejects image blocks instead of silently flattening them away', () => { - expect(() => serializeMessages([createUserMessage({ - content: [{ - type: 'image', - attachment: { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', bytes: 68, width: 1, height: 1, - }, - }], - source: { kind: 'test' }, - })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + it('preserves empty results without inventing model-visible output', () => { + const response = body([user(), assistant([call()]), result('a', [])]) + expect(response.messages[2]?.content[0]).toMatchObject({ content: [] }) + const { isError: _isError, ...minimal } = createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: '' }], isError: false }) + expect(body([assistant([call()]), minimal]).messages[1]?.content[0]).toEqual({ type: 'tool_result', tool_use_id: 'a', content: [] }) }) - it('emits an empty user message rather than dropping block-less messages', () => { - const wire = serializeMessages([createUserMessage({ - content: [], - source: { kind: 'test' }, - })]) - expect(wire).toEqual([{ role: 'user', content: '' }]) + it('omits assistant-only blocks from user input while preserving text and durable content', () => { + const history = [createMessage({ role: 'user', source: { kind: 'user' }, content: [ + { type: 'text', text: 'Background subagent finished.\n' }, + { type: 'reasoning', text: 'child reasoning' }, + call('child-call'), + { type: 'text', text: ' child answer ' }, + ] })] + const saved = JSON.stringify(history) + + expect(body(history).messages).toEqual([{ role: 'user', content: [ + { type: 'text', text: 'Background subagent finished.\n' }, + { type: 'text', text: ' child answer ' }, + ] }]) + expect(JSON.stringify(history)).toBe(saved) + }) + + it('omits assistant-only blocks inside tool results while retaining calls, errors and empty results', () => { + const reasoning: ContentBlock = { type: 'reasoning', text: 'tool reasoning' } + const history = [assistant([reasoning, call(), call('b')]), + createToolResultMessage({ callId: ToolCallId('a'), content: [reasoning, call('nested-call'), { type: 'text', text: ' result\n' }], isError: true }), + result('b', [reasoning, call('another-nested-call')]), + ] + const saved = JSON.stringify(history) + + expect(body(history).messages).toEqual([ + { role: 'assistant', content: [ + { type: 'thinking', thinking: 'tool reasoning' }, + ...['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })), + ] }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: ' result\n' }], is_error: true }, + { type: 'tool_result', tool_use_id: 'b', content: [], is_error: false }, + ] }, + ]) + expect(JSON.stringify(history)).toBe(saved) + }) + + it.each([ + [], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')], + ] satisfies ContentBlock[][])('omits empty user input after conversion %#', (...content) => { + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) + expect(body([empty, user(), assistant([{ type: 'text', text: 'answer' }]), empty]).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, + ]) + }) + + it('collects leading system text and maps tools, stop sequences and explicit output cap', () => { + const system = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'instructions' }] }) + expect(body([system, user()], { system: 'top', maxTokens: 123, stop: ['END'], tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }] })).toMatchObject({ + system: 'top\n\ninstructions', max_tokens: 123, stop_sequences: ['END'], tools: [{ name: 'read', description: 'Read a file', input_schema: { type: 'object' } }], + }) + expect(body([user(), system]).system).toBe('instructions') + }) + + it('uses the latest complete system snapshot without changing tool history or durable messages', () => { + const conversation = [user(), assistant([call()]), result(), assistant([{ type: 'text', text: 'done' }]), user('continue')] + const history = [createSystemMessage('obsolete'), ...conversation.slice(0, 2), + createSystemMessage('intermediate'), ...conversation.slice(2, 4), + createSystemMessage('current'), conversation[4]!] + const saved = JSON.stringify(history) + expect(body(history)).toEqual({ ...body(conversation), system: 'current' }) + expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix\n\ncurrent') + expect(JSON.stringify(history)).toBe(saved) + }) + + it('replaces adjacent system snapshots and joins blocks only within the current snapshot', () => { + const latest = createMessage({ role: 'system', source: { kind: 'system-prompt' }, + content: [{ type: 'text', text: 'part one' }, { type: 'text', text: ' and part two' }] }) + expect(body([createSystemMessage('old'), latest, user()]).system).toBe('part one and part two') + }) + + it.each([[], [{ type: 'text' as const, text: '' }]].map(content => ({ content })))('clears earlier prompt snapshots with empty content %#', ({ content }) => { + const cleared = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content }) + const history = [createSystemMessage('old'), user(), cleared] + expect(body(history).system).toBeUndefined() + expect(body(history, { system: 'one-shot prefix' }).system).toBe('one-shot prefix') + expect(body(history, { system: '' }).system).toBeUndefined() + }) + + it('rejects non-text system content even when a later snapshot supersedes it', () => { + const invalid = createMessage({ role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'reasoning', text: 'bad' }] }) + expect(() => body([invalid, user(), createSystemMessage('current')])).toThrow(/non-text system/) + }) + + it.each(['off', 'low', 'high', 'max'])('maps reasoning effort %s', (effort) => { + const request = body([user()], { reasoningEffort: ReasoningEffortId(effort) }) + expect(request.thinking.type).toBe(effort === 'off' ? 'disabled' : 'enabled') + expect(request.output_config).toEqual(effort === 'off' ? undefined : { effort }) + }) + + it('disables thinking for titles, passes temperature with thinking and refuses unsupported effort', () => { + expect(body([user()], { purpose: 'session-title', temperature: 0 })).toMatchObject({ thinking: { type: 'disabled' }, temperature: 0 }) + expect(body([user()], { temperature: 0 })).toMatchObject({ thinking: { type: 'enabled' }, temperature: 0 }) + expect(() => body([user()], { reasoningEffort: ReasoningEffortId('medium') })).toThrow(/effort/) + const disabled = resolveAdapterOptions({ thinking: 'disabled' }) + expect(serialize(options(), disabled, [user()], new Map(), () => undefined).thinking).toEqual({ type: 'disabled' }) + expect(() => serialize(options({ reasoningEffort: ReasoningEffortId('high') }), disabled, [user()], new Map(), () => undefined)).toThrow(/effort/) + const capped = resolveAdapterOptions({ models: [{ id: MODEL, maxTokens: 321 }] }) + expect(serialize(options(), capped, [user()], new Map(), () => undefined).max_tokens).toBe(321) + }) + + it.each([ + [result()], [assistant([call()])], [assistant([call()]), user()], + [assistant([call(), call()]), result()], + [assistant([call()]), result(), result()], + ])('rejects unmatched or duplicated tool history %#', (...messages) => { + expect(() => body(messages)).toThrow(/tool/) + }) + + it.each(['{', '', '[]', 'null', '42', 'true', '"text"', '{"description":"最快,但"某个说法"没有证据。"}'])('uses empty input for malformed or non-object historical tool arguments %s', (arguments_) => { + const message = assistant([{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: arguments_ }]) + const history = [user(), message, createToolResultMessage({ callId: ToolCallId('a'), content: [{ type: 'text', text: 'Invalid arguments' }], isError: true }), user('Continue')] + const saved = JSON.stringify(history) + const restored = JSON.parse(saved) as Message[] + expect(body(restored).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'a', name: 'read', input: {} }] }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: 'Invalid arguments' }], is_error: true }, + { type: 'text', text: 'Continue' }, + ] }, + ]) + expect(JSON.stringify(restored)).toBe(saved) + }) + + it('preserves own signed thinking, omits absent signatures and validates durable metadata', () => { + const content: ContentBlock[] = [{ type: 'reasoning', text: '' }, { type: 'text', text: 'answer' }] + const source = { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: 'signed' }, { type: 'text' }]) } + const message = createAssistantMessage({ content, source }) + expect(body([user(), message, user()]).messages[1]?.content).toEqual([{ type: 'thinking', thinking: '', signature: 'signed' }, { type: 'text', text: 'answer' }]) + expect(body([assistant([{ type: 'reasoning', text: 'foreign thought' }])]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'foreign thought' }]) + expect(readReplay(message, 'different-model')).toBeUndefined() + expect(readReplay(user(), MODEL)).toBeUndefined() + }) + + it.each([ + null, + [], + { response: null, blocks: [] }, + { response: { kind: 'other', version: 1 }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: 'wrong' }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: null }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [null] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'tool-call' }] }, + { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'reasoning', signature: 3 }] }, + ].map(state => ({ state })))('degrades unusable replay state with a diagnostic %#', ({ state }) => { + const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { provider: 'deepseek-official', model: MODEL, replayState: state } }) + const onDegrade = vi.fn() + expect(readReplay(message, MODEL, onDegrade)).toBeUndefined() + expect(onDegrade).toHaveBeenCalledExactlyOnceWith(expect.any(String)) + expect(body([message]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'think' }]) + }) + + it.each([MODEL, 'different-model'])('keeps durable content when replay degrades for %s', async (model) => { + const message = createAssistantMessage({ + content: [{ type: 'reasoning', text: 'Read the file.' }, { type: 'text', text: 'Checking a.' }, call()], + source: { provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [ + { type: 'reasoning', signature: 'do-not-send' }, { type: 'text', signature: 'invalid-for-text' }, { type: 'tool-call' }, + ]) }, + }) + const saved = JSON.stringify(message) + const restored = JSON.parse(saved) as Message + const messages = [user(), restored, result()] + const onDegrade = vi.fn() + const request = serialize(options({ model }), connection, messages, new Map(), () => undefined, onDegrade) + expect(onDegrade).toHaveBeenCalledExactlyOnceWith('DeepSeek Messages replay: invalid signature') + await expect(JSON.stringify(request.messages, null, 2) + '\n').toMatchFileSnapshot('expected/degraded-replay.json') + expect(JSON.stringify(restored)).toBe(saved) + }) + + it('keeps valid cross-model and foreign history quiet and propagates diagnostic failures', () => { + const onDegrade = vi.fn() + const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: '' }]), + } }) + expect(readReplay(message, MODEL, onDegrade)).toEqual([{ type: 'reasoning', signature: '' }]) + expect(readReplay(message, 'different-model', onDegrade)).toBeUndefined() + expect(readReplay(assistant([{ type: 'text', text: 'foreign' }]), MODEL, onDegrade)).toBeUndefined() + expect(onDegrade).not.toHaveBeenCalled() + const damaged = { ...message, source: { ...message.source, replayState: { response: {}, blocks: [] } } } + const failure = new Error('diagnostic failed') + expect(() => readReplay(damaged, MODEL, () => { throw failure })).toThrow(failure) + }) + + it.each([1, 2])('uses empty historical tool input with replay version %s', (version) => { + const message = createAssistantMessage({ content: [{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: '{' }], source: { + provider: 'deepseek-official', model: MODEL, replayState: { response: { kind: 'deepseek-messages', version, model: MODEL }, blocks: [{ type: 'tool-call' }] }, + } }) + const saved = JSON.stringify(message) + const onDegrade = vi.fn() + const request = serialize(options(), connection, [message, result()], new Map(), () => undefined, onDegrade) + expect(request.messages[0]?.content).toEqual([{ type: 'tool_use', id: 'a', name: 'read', input: {} }]) + expect(onDegrade).toHaveBeenCalledTimes(version === 1 ? 0 : 1) + expect(JSON.stringify(message)).toBe(saved) }) }) -describe('serializeRequest', () => { - const history: Message[] = [createUserMessage({ - content: [{ type: 'text', text: 'hi' }], - source: { kind: 'test' }, - })] +describe('validated configuration', () => { + it('advertises exact model metadata and allows unlisted text models', () => { + expect(modelInfo(connection, 'deepseek-official', MODEL)).toMatchObject({ context: { contextWindow: 1_000_000 }, defaultMaxTokens: 256_000, reasoning: { defaultEffort: 'high' } }) + expect(modelInfo(connection, 'deepseek-official', 'custom').inputModalities).toEqual(['text']) + expect(modelInfo(connection, 'deepseek-official', MODEL).systemPromptUpdate).toBeUndefined() + expect(modelInfo(connection, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() + expect(modelInfo(capable, 'deepseek-official', MODEL).systemPromptUpdate).toBe('in-history') + expect(modelInfo(capable, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined() + expect(modelInfo(resolveAdapterOptions({ thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }]) + expect(resolveAdapterOptions({ baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///') + }) + it.each([ + { thinking: 'disabled', reasoningEffort: 'high' }, { models: [{ id: '' }] }, + { models: [{ id: 'a' }, { id: 'a' }] }, { models: [{ id: 'a', name: '' }] }, + { maxInlineRequestImageBytes: 1 }, { maxImagesPerRequest: 1 }, + { baseURL: 'ftp://example.com' }, { baseURL: 'https://user:pass@example.com' }, + { baseURL: 'https://example.com/?key=x' }, { baseURL: 'https://example.com/#x' }, + { maxTokens: 0 }, { streamIdleTimeoutMs: 0 }, + { models: [{ id: MODEL, systemPromptUpdate: 'unsupported' }] }, + ])('rejects invalid composition input %#', (value) => { + expect(() => resolveAdapterOptions(value as Config)).toThrow() + }) +}) - it('always streams with usage and maps the basics', () => { - const wire = serializeRequest(request({ messages: history })) - expect(wire).toEqual({ - model: 'deepseek-v4-flash', - messages: [{ role: 'user', content: 'hi' }], - stream: true, - stream_options: { include_usage: true }, +describe('Messages images', () => { + const ref: ImageAttachmentRef = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), mediaType: 'image/png', width: 1, height: 1, bytes: 3 } + const image: ImageBlock = { type: 'image', attachment: ref } + const version: RequestImageAttachment = { attachment: ref, variantId: ImageVariantId(`sha256:${'b'.repeat(64)}`), mediaType: 'image/png', bytes: 3, data: Uint8Array.of(1, 2, 3), width: 1, height: 1, depth: 'uchar', space: 'srgb', hasAlpha: false } + const access = () => ({ readonlyPath: '/workspace/image.png' }) + const model = 'deepseek-flash' + // Only the read operation is consumed by image preparation; the transport is mocked, not durable content. + const attachments = requestImageStore(async () => version) + const signal = new AbortController().signal + it('keeps image bytes inside tool results and deduplicates normalization', async () => { + const history = [assistant([call()]), result('a', [image, image])] + const prepared = await prepareImages(history, connection, model, attachments, access, signal) + expect(prepared.versions.size).toBe(1) + const request = serialize(options({ model }), connection, prepared.messages, prepared.versions, access) + expect(request.messages[1]?.content[0]).toMatchObject({ type: 'tool_result', content: [ + { type: 'text', text: expect.stringContaining('/workspace/image.png') as string }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, + { type: 'text' }, { type: 'image' }, + ] }) + expect(imagePricing(connection, model, access).priceImages([image])[0]?.visualTokens).toBeGreaterThan(0) + expect(imagePricing(connection, MODEL, access).priceImages([image])[0]?.visualTokens).toBe(0) + }) + it('requires logged offload at exact encoded bytes and preserves durable references', async () => { + const config = resolveAdapterOptions({ + maxInlineRequestImageBytes: 4, inlineImageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1, }) - }) - - it('prepends the system prompt', () => { - const wire = serializeRequest(request({ messages: history, system: 'be helpful' })) - expect(wire.messages[0]).toEqual({ role: 'system', content: 'be helpful' }) - expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' }) - }) - - it('serializes a leading system message byte-for-byte like the same prompt passed as options.system', async () => { - const systemMessage = createMessage({ - role: 'system', - content: [{ type: 'text', text: 'be helpful' }], - source: { kind: 'system-prompt' }, - }) - const tools = [{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } }] - const fromHistory = serializeRequest(request({ messages: [systemMessage, ...history], tools })) - const fromOption = serializeRequest(request({ messages: history, system: 'be helpful', tools })) - expect(fromHistory.messages[0]).toEqual({ role: 'system', content: 'be helpful' }) - expect(JSON.stringify(fromHistory)).toBe(JSON.stringify(fromOption)) - const images = imageOptions([]) - const imageHistory = await serializeRequestWithImages(request({ messages: [systemMessage, ...history], tools }), images) - const imageOption = await serializeRequestWithImages(request({ messages: history, system: 'be helpful', tools }), images) - expect(JSON.stringify(imageHistory)).toBe(JSON.stringify(imageOption)) - }) - - it('maps sampling params and stop sequences', () => { - const wire = serializeRequest(request({ messages: history, temperature: 0.2, maxTokens: 100, stop: ['END'] })) - expect(wire.temperature).toBe(0.2) - expect(wire.max_tokens).toBe(100) - expect(wire.stop).toEqual(['END']) - }) - - it('maps tools to the wire function shape', () => { - const wire = serializeRequest(request({ - messages: history, - tools: [ - { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } }, - { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } }, - ], + const offloadFailure: unknown = expect.objectContaining({ code: 'IMAGE_OFFLOAD_REQUIRED', offloadImages: 1 }) + const history = [result('a', [image, image])] + const prepared = await prepareImages(history, config, model, attachments, access, signal) + expect(prepared.messages[0]?.content).toMatchObject([image, image]) + expect(() => inlineImages(prepared.messages, prepared.versions, config)).toThrow(expect.objectContaining({ + failure: offloadFailure, + })) + const offloaded: ImageBlock = { ...image, offloaded: true } + const retry = await prepareImages([result('a', [offloaded, image])], config, model, attachments, access, signal) + expect(inlineImages(retry.messages, retry.versions, config)[0]?.content).toMatchObject([{ type: 'text' }, { type: 'image' }]) + expect(history[0]?.content).toMatchObject([image, image]) + expect(imagePricing(config, model, access).priceImages([image, image]).map(entry => entry.visualTokens)) + .toEqual([expect.any(Number), expect.any(Number)]) + const large = requestImageStore(async () => ({ ...version, bytes: 30, data: new Uint8Array(30) })) + const exact = await prepareImages([result('a', [image])], config, model, large, access, signal) + expect(() => inlineImages(exact.messages, exact.versions, config)).toThrow(expect.objectContaining({ + failure: offloadFailure, })) - expect(wire.tools).toEqual([ - { type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } }, - { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } }, - ]) }) - - it('omits an empty tools array', () => { - const wire = serializeRequest(request({ messages: history, tools: [] })) - expect(wire.tools).toBeUndefined() - }) - - it.each(['low', 'high', 'max'] as const)('maps adapter-default thinking and request effort %s', (effort) => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId(effort) }), - { thinking: 'enabled', reasoningEffort: 'high' }, - ) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBe(effort) - }) - - it('maps off to disabled thinking without a wire reasoning effort', () => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('off') }), - { thinking: 'enabled', reasoningEffort: 'max' }, - ) - expect(wire.thinking).toEqual({ type: 'disabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('re-enables thinking when max overrides an off default', () => { - const wire = serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('max') }), - { reasoningEffort: 'off' }, - ) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBe('max') - }) - - it('rejects enabling thinking when the deployment is locked to disabled', () => { - expect(() => serializeRequest( - request({ messages: history, reasoningEffort: ReasoningEffortId('high') }), - { thinking: 'disabled' }, - )).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) - }) - - it('disables thinking for session-title requests without changing adapter defaults', () => { - const wire = serializeRequest( - request({ - messages: history, - purpose: 'session-title', - reasoningEffort: ReasoningEffortId('max'), - }), - { thinking: 'enabled', reasoningEffort: 'max' }, - ) - expect(wire.thinking).toEqual({ type: 'disabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('omits thinking fields when unset (provider default applies)', () => { - const wire = serializeRequest(request({ messages: history })) - expect(wire.thinking).toBeUndefined() - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('preserves an explicit enabled default without inventing a wire effort', () => { - const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled' }) - expect(wire.thinking).toEqual({ type: 'enabled' }) - expect(wire.reasoning_effort).toBeUndefined() - }) - - it('rejects an effort outside the DeepSeek capability', () => { - expect(() => serializeRequest(request({ - messages: history, - reasoningEffort: ReasoningEffortId('medium'), - }))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) - }) -}) - -describe('image serialization', () => { - it.each([ - 'image/png', - 'image/jpeg', - 'image/webp', - 'image/gif', - ] as const)('preserves ordered text and %s image parts', async (mediaType) => { - const resolveFileId = fileResolver() - const ref = imageRef(mediaType) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'before' }, - { type: 'image', attachment: ref }, - { type: 'text', text: 'after' }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref], resolveFileId)) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { type: 'text', text: 'before' }, - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, - { type: 'file', file_id: 'file-api-image' }, - { type: 'text', text: 'after' }, - ], - }]) - }) - - it.each([ - ['image/png', 'data:image/png;base64,AAAA'], - ['image/jpeg', 'data:image/jpeg;base64,AAAA'], - ['image/webp', 'data:image/webp;base64,AAAA'], - ['image/gif', 'data:image/gif;base64,AAAA'], - ] as const)('serializes every retained %s request version as an inline data URL', async (mediaType, url) => { - const ref = imageRef(mediaType) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), inlineImageOptions([ref])) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { type: 'text', text: expect.stringContaining(`Image ${ref.attachmentId}; request preview 1x1px`) as string }, - { type: 'image_url', image_url: { url } }, - ], - }]) - }) - - it('gives image-only input a stable handle and request dimensions', async () => { - const ref = imageRef() - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref])) - - expect(wire.messages).toEqual([{ - role: 'user', - content: [ - { - type: 'text', - text: `Image ${ref.attachmentId}; request preview 1x1px. It may be resized or re-encoded; source dimensions, format, and byte size may differ.`, - }, - { type: 'file', file_id: 'file-api-image' }, - ], - }]) - }) - - it('includes provider-resolved normalized access in a retained image handle', async () => { - const ref = { ...imageRef(), name: 'diagram.png', width: 2048, height: 1024 } - const images = imageOptions([ref]) - const version = images.requestImages.get(ref.attachmentId) as RequestImageAttachment - version.width = 1130 - version.height = 565 - images.resolveImageAccess = () => ({ readonlyPath: '/tmp/dsh/objects/aa/object' }) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), images) - - expect(wire.messages[0]).toMatchObject({ - role: 'user', - content: [{ - type: 'text', - text: expect.stringContaining('Image "diagram.png"') as string, - }, { type: 'file' }], - }) - expect(JSON.stringify(wire.messages[0])).toContain('/tmp/dsh/objects/aa/object') - expect(JSON.stringify(wire.messages[0])).toContain('request preview 1130x565px') - }) - - it('rejects an image whose prepared request version is absent', async () => { - const ref = imageRef() - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'test' }, - })], imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) - }) - - it('keeps tool content textual and groups consecutive tool-result images afterward', async () => { - const messages = [ - createToolResultMessage({ - callId: ToolCallId('first'), - content: [{ type: 'image', attachment: imageRef() }], - isError: false, - }), - createToolResultMessage({ - callId: ToolCallId('second'), - content: [ - { type: 'text', text: 'caption' }, - { type: 'image', attachment: imageRef('image/jpeg') }, - ], - isError: false, - }), - ] - - const png = imageRef() - const jpeg = imageRef('image/jpeg') - await expect(serializeMessagesWithImages(messages, imageOptions( - [png, jpeg], - vi.fn((version: RequestImageAttachment) => Promise.resolve(`file-api-${version.mediaType}`)), - ))).resolves.toEqual([ - { - role: 'tool', - tool_call_id: 'first', - content: expect.stringContaining(`Image ${png.attachmentId}`) as string, - }, - { - role: 'tool', - tool_call_id: 'second', - content: expect.stringContaining(`caption\nImage ${jpeg.attachmentId}`) as string, - }, - { - role: 'user', - content: [ - { type: 'text', text: 'Attached image(s) from tool result:' }, - { type: 'file', file_id: 'file-api-image/png' }, - { type: 'file', file_id: 'file-api-image/jpeg' }, - ], - }, - ]) - }) - - it('keeps an empty user message for ignored content beside a tool message', async () => { - const messages = [ - createUserMessage({ - content: [ - { type: 'text', text: '' }, - { type: 'chart', data: 'ignored' } as unknown as ContentBlock, - ], - source: { kind: 'test' }, - }), - createToolResultMessage({ - callId: ToolCallId('result'), - content: [{ type: 'text', text: 'ok' }], - isError: false, - }), - ] - - await expect(serializeMessagesWithImages(messages, imageOptions([], fileResolver()))).resolves.toEqual([ - { role: 'user', content: '' }, - { role: 'tool', tool_call_id: 'result', content: 'ok' }, - ]) - }) - - it('converts consecutive tool messages and preserves the empty fallback', async () => { - const messages = [ - createToolResultMessage({ - callId: ToolCallId('nested'), - content: [{ type: 'text', text: 'inside' }], - isError: false, - }), - createToolResultMessage({ callId: ToolCallId('empty'), content: [], isError: false }), - ] - - await expect(serializeMessagesWithImages(messages, imageOptions([], fileResolver()))).resolves.toEqual([ - { role: 'tool', tool_call_id: 'nested', content: 'inside' }, - { role: 'tool', tool_call_id: 'empty', content: '(no output)' }, - ]) - }) - - it('flushes tool-result images before system and assistant history', async () => { - const imageResult = (id: string) => createToolResultMessage({ - callId: ToolCallId(id), - content: [{ type: 'image', attachment: imageRef() }], - isError: false, - }) - const messages = [ - imageResult('before-system'), - createMessage({ - role: 'system', - content: [{ type: 'text', text: 'system history' }], - source: { kind: 'system-prompt' }, - }), - imageResult('before-assistant'), - createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'assistant history' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }), - ] - - const wire = await serializeMessagesWithImages(messages, imageOptions([imageRef()], fileResolver())) - expect(wire).toEqual([ - { - role: 'tool', - tool_call_id: 'before-system', - content: expect.stringContaining('request preview 1x1px') as string, - }, - expect.objectContaining({ role: 'user' }), - { role: 'system', content: 'system history' }, - { - role: 'tool', - tool_call_id: 'before-assistant', - content: expect.stringContaining('request preview 1x1px') as string, - }, - expect.objectContaining({ role: 'user' }), - { role: 'assistant', content: 'assistant history' }, - ]) - }) - - it('projects surface-offloaded occurrences to placeholders without resolving them', async () => { - const resolveFileId = fileResolver() - const png = imageRef('image/png', 3) - const jpeg = imageRef('image/jpeg', 3) - const images = imageOptions([png, jpeg], resolveFileId, 4) - images.resolveImageAccess = ref => ref.mediaType === 'image/png' - ? { readonlyPath: '/tmp/dsh/objects/png' } - : undefined - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - { type: 'image', attachment: png, offloaded: true }, - { type: 'image', attachment: jpeg }, - ], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), images) - - expect(wire.messages[0]).toMatchObject({ - role: 'user', - content: [ - { - type: 'text', - text: expect.stringContaining(`image omitted to fit request image limits; ${png.attachmentId}. Normalized copy (read-only; may be resized or re-encoded): "/tmp/dsh/objects/png"`) as string, - }, - { type: 'text', text: expect.stringContaining(`Image ${jpeg.attachmentId}`) as string }, - { type: 'file', file_id: 'file-api-image' }, - ], - }) - expect(resolveFileId).toHaveBeenCalledTimes(1) - expect(resolveFileId.mock.calls[0]?.[0]).toMatchObject({ attachment: { mediaType: 'image/jpeg' } }) - }) - it('rejects retained inline images beyond the bound with the quantized count to offload', async () => { - const ref = imageRef('image/png', 3) - // 21 retained 3-byte images encode to 84 base64 bytes against an 80-byte bound with a - // 40-byte quantum: the removal crosses the quantum at the eleventh oldest occurrence. - await expect(serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: Array.from({ length: 21 }, () => ({ type: 'image' as const, attachment: ref })), - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), inlineImageOptions([ref], 80, 40))).rejects.toMatchObject({ - code: 'IMAGE_OFFLOAD_REQUIRED', - failure: { offloadImages: 11 }, - }) - }) - - it('uses the configured image-count quantum before resolving files', async () => { - const ref = imageRef() - const resolveFileId = fileResolver() - const messages = [createUserMessage({ - content: Array.from({ length: 3 }, () => ({ type: 'image' as const, attachment: ref })), - source: { kind: 'test' }, - })] - await expect(serializeRequestWithImages(request({ messages }), { - ...imageOptions([ref], resolveFileId), maxImagesPerRequest: 2, countQuantum: 2, - })).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', failure: { offloadImages: 2 } }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('counts only retained occurrences against the bound', async () => { - const ref = imageRef('image/png', 3) - const wire = await serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [ - ...Array.from({ length: 11 }, () => ({ type: 'image' as const, attachment: ref, offloaded: true as const })), - ...Array.from({ length: 10 }, () => ({ type: 'image' as const, attachment: ref })), - ], - source: { kind: 'test' }, - })], - }), inlineImageOptions([ref], 80, 40)) - const content = wire.messages[0]?.content - expect(JSON.stringify(content).match(/image omitted to fit request image limits/g)).toHaveLength(11) - expect(JSON.stringify(content).match(/"type":"image_url"/g)).toHaveLength(10) - }) - it('rejects an unprepared image while computing exact request bytes', async () => { - const ref = imageRef() - await expect(serializeRequestWithImages(request({ - model: 'deepseek-v4-flash-vision-exp', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([]))).rejects.toMatchObject({ code: 'INVALID_REQUEST' }) - }) - - it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => { - const resolveFileId = vi.fn() - const content: ContentBlock[] = [{ type: 'image', attachment: imageRef() }] - const message = role === 'system' - ? createMessage({ role, content, source: { kind: 'system-prompt' } }) - : createMessage({ role, content, source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) - await expect(serializeMessagesWithImages([message], imageOptions([imageRef()], resolveFileId))) - .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('rejects developer history on text and image paths before reading attachments', async () => { - const resolveFileId = fileResolver() - const message = createDeveloperMessage({ - content: [{ type: 'tool-addition', toolName: 'search' }], - source: { kind: 'test' }, - }) - const failure = { code: 'UNSUPPORTED_CONTENT', message: 'Developer messages are not supported yet' } - expect(() => serializeMessages([message])).toThrow(expect.objectContaining(failure)) - await expect(serializeMessagesWithImages([message], imageOptions([], resolveFileId))) - .rejects.toMatchObject(failure) - await expect(serializeRequestWithImages(request({ messages: [message] }), imageOptions([], resolveFileId))) - .rejects.toMatchObject(failure) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('rejects unsupported image history before request offloading can replace it', async () => { - const resolveFileId = vi.fn() - await expect(serializeRequestWithImages(request({ - messages: [createMessage({ - role: 'system', - content: [{ type: 'image', attachment: imageRef('image/png', 300) }], - source: { kind: 'system-prompt' }, - })], - }), imageOptions([imageRef('image/png', 300)], resolveFileId, 1))) - .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) - expect(resolveFileId).not.toHaveBeenCalled() - }) - - it('prepends the request system prompt on the image path', async () => { - const ref = imageRef() - const wire = await serializeRequestWithImages(request({ - system: 'system prompt', - messages: [createUserMessage({ - content: [{ type: 'image', attachment: ref }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })], - }), imageOptions([ref])) - expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' }) - }) - - it('preserves stable file-resolution failure codes', async () => { - const failure = new Error('Stored attachment bytes are corrupt.') as Error & { code: string } - failure.code = 'ATTACHMENT_CORRUPT' - const resolveFileId = vi.fn(() => Promise.reject(failure)) - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: imageRef() }], - source: { kind: 'test' }, - })], imageOptions([imageRef()], resolveFileId))) - .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) - }) - - it('preserves non-attachment resolver failures', async () => { - const failure = new Error('resolver failed') - const resolveFileId = vi.fn(() => Promise.reject(failure)) - await expect(serializeMessagesWithImages([createUserMessage({ - content: [{ type: 'image', attachment: imageRef() }], - source: { kind: 'test' }, - })], imageOptions([imageRef()], resolveFileId))).rejects.toBe(failure) - }) -}) - -describe('review fixes: assistant content shapes', () => { - it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => { - // Aborted/empty assistant turns: no text, no calls → "". The earlier - // null shape was live-falsified: the API 400s a null-content assistant - // message without tool_calls ("content or tool_calls must be set"). - const wire = serializeMessages([createMessage({ - role: 'assistant', content: [], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire).toEqual([{ role: 'assistant', content: '' }]) - }) - - it('serializes a reasoning-ONLY assistant message as "" content beside its reasoning', () => { - // The model can answer entirely in the reasoning channel (a v4-flash - // greeting did, live). Content must still be SET — a null here poisoned - // the session log and bricked every later turn of that session. - const wire = serializeMessages([createMessage({ - role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire).toEqual([{ - role: 'assistant', content: '', reasoning_content: '你好!有什么我可以帮你的吗?', - }]) - }) - - it('serializes tool-call turns with empty string content, not null', () => { - const wire = serializeMessages([createMessage({ - role: 'assistant', - content: [{ type: 'tool-call', id: ToolCallId('c'), name: 'f', arguments: '{}' }], - source: { kind: 'model', provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })]) - expect(wire[0]).toMatchObject({ content: '' }) + it('rejects unsupported roles and unavailable image capabilities before HTTP', async () => { + const history = [result('a', [image])] + await expect(prepareImages(history, connection, MODEL, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(prepareImages(history, connection, model, undefined, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(prepareImages([assistant([image])], connection, model, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(() => body([result('a', [image])])).toThrow(/image/) + expect(() => body([assistant([image])])).toThrow(/assistant/) + // @ts-expect-error -- malformed provider input can carry a retired nested result block. + expect(() => body([result('a', [{ type: 'tool-result', toolCallId: ToolCallId('nested'), content: [] }])])) + .toThrow(/user\/tool-result content tool-result/) + expect(() => serialize(options({ model }), connection, [result('a', [image])], new Map([[ref.attachmentId, version]]), access, undefined, new Map())) + .toThrow(/request file id is missing/) }) }) diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts deleted file mode 100644 index bdf1508c60..0000000000 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { LlmError } from '@deepseek-ai/dsh-llm' -import { DONE, parseSse } from '../src/protocols/chat-completions/sse.ts' - -/** - * DeepSeek protocol contract only: the [DONE] sentinel and STREAM_CLOSED on - * EOF without it. SSE framing (chunk splits, CRLF, multi-data joins, comments) - * is eventsource-parser's contract, not re-proven here. - */ - -/** Build an SSE byte stream from string fragments (fragments = network reads). */ -function bytes(...fragments: string[]): ReadableStream> { - const encoder = new TextEncoder() - return new ReadableStream({ - start(controller) { - for (const fragment of fragments) controller.enqueue(encoder.encode(fragment)) - controller.close() - }, - }) -} - -async function collect(stream: AsyncIterable): Promise { - const out: string[] = [] - for await (const item of stream) out.push(item) - return out -} - -describe('parseSse', () => { - it('yields event payloads and the DONE sentinel', async () => { - const events = await collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]\n\n'))) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('reports comments out of band without yielding them', async () => { - const comments: string[] = [] - const events = await collect(parseSse( - bytes(': keep-alive\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'), - (comment) => { comments.push(comment) }, - )) - expect(comments).toEqual(['keep-alive']) - expect(events).toEqual(['{"a":1}', DONE]) - }) - - it('stops yielding after DONE even when more data follows', async () => { - const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) - expect(events).toEqual([DONE]) - }) - - it('throws STREAM_CLOSED when the stream ends without DONE', async () => { - await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(LlmError) - await expect(collect(parseSse(bytes('data: {"a":1}\n\n')))).rejects.toThrow(/without \[DONE\]/) - }) - - it('throws STREAM_CLOSED for an empty stream', async () => { - await expect(collect(parseSse(bytes()))).rejects.toThrow(/without \[DONE\]/) - }) - - it('throws STREAM_CLOSED for a mid-event close', async () => { - await expect(collect(parseSse(bytes('data: {"a"')))).rejects.toThrow(/without \[DONE\]/) - }) - - it('treats a final DONE missing its blank-line terminator as truncation', async () => { - // Spec-strict framing: an event dispatches only on its blank-line - // terminator, so an unterminated tail at EOF is STREAM_CLOSED — real - // providers always terminate events, so a missing terminator is truncation. - await expect(collect(parseSse(bytes('data: {"a":1}\n\ndata: [DONE]')))).rejects.toThrow(/without \[DONE\]/) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/messages/stream.spec.ts b/packages/llm/llm-deepseek/tests/stream.spec.ts similarity index 97% rename from packages/llm/llm-deepseek/tests/messages/stream.spec.ts rename to packages/llm/llm-deepseek/tests/stream.spec.ts index 51d54beef0..aa86d48404 100644 --- a/packages/llm/llm-deepseek/tests/messages/stream.spec.ts +++ b/packages/llm/llm-deepseek/tests/stream.spec.ts @@ -1,8 +1,8 @@ /** Protocol invariants at JSON/SSE boundaries, including partial and failed responses. */ import { describe, expect, it } from 'vitest' -import { translate } from '../../src/protocols/messages/translate.ts' -import { parseSse } from '../../src/protocols/messages/sse.ts' -import { providerError } from '../../src/protocols/messages/transport.ts' +import { translate } from '../src/translate.ts' +import { parseSse } from '../src/sse.ts' +import { providerError } from '../src/transport.ts' import { assemble, chunks, end, events, MODEL, sse, start, textEvents } from './helpers.ts' describe('Messages stream', () => { diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts deleted file mode 100644 index 5e19f0c94f..0000000000 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { BlockAssembler, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm' -import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { DONE } from '../src/protocols/chat-completions/sse.ts' -import { mapFinishReason, mapUsage, translate } from '../src/protocols/chat-completions/translate.ts' - -async function* feed(...payloads: (string | object)[]): AsyncGenerator { - for (const payload of payloads) { - yield typeof payload === 'string' ? payload : JSON.stringify(payload) - } -} - -async function collect(stream: AsyncIterable): Promise { - const out: StreamChunk[] = [] - for await (const chunk of stream) out.push(chunk) - return out -} - -/** The live first-chunk signature: role + null content + EMPTY reasoning. */ -const firstChunk = { choices: [{ delta: { role: 'assistant', content: null, reasoning_content: '' } }] } - -describe('translate: text', () => { - it('streams a text block and defers finish to DONE', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'Hel' } }] }, - { choices: [{ delta: { content: 'lo' } }] }, - { choices: [{ delta: { content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'text-delta', index: 0, text: 'Hel' }, - { type: 'text-delta', index: 0, text: 'lo' }, - { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello' } }, - { type: 'usage', usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }, - { type: 'finish', reason: { kind: 'stop' } }, - ]) - }) - - it('assembles into the message BlockAssembler expects', async () => { - const assembler = new BlockAssembler() - for await (const chunk of translate(feed( - firstChunk, - { choices: [{ delta: { content: 'hi' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) { - assembler.push(chunk) - } - const result = { message: assembler.message({ provider: 'mock', model: 'mock' }), finish: assembler.finish } - expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }]) - expect(result.finish).toEqual({ kind: 'stop' }) - }) -}) - -describe('translate: reasoning', () => { - it('does NOT open a reasoning block for the empty first-chunk signature', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'plain' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.some(chunk => chunk.type === 'block-start' && chunk.blockType === 'reasoning')).toBe(false) - }) - - it('streams reasoning then text as separate blocks', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: null, reasoning_content: 'think' } }] }, - { choices: [{ delta: { content: null, reasoning_content: 'ing' } }] }, - { choices: [{ delta: { content: 'answer', reasoning_content: null } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'reasoning' }, - { type: 'reasoning-delta', index: 0, text: 'think' }, - { type: 'reasoning-delta', index: 0, text: 'ing' }, - { type: 'block-start', index: 1, blockType: 'text' }, - { type: 'text-delta', index: 1, text: 'answer' }, - { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'thinking' } }, - { type: 'block-end', index: 1, block: { type: 'text', text: 'answer' } }, - { type: 'finish', reason: { kind: 'stop' } }, - ]) - }) - - it('treats an entirely absent reasoning_content field as non-thinking', async () => { - const chunks = await collect(translate(feed( - { choices: [{ delta: { role: 'assistant', content: 'x' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-start')).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - ]) - }) -}) - -describe('translate: tool calls', () => { - it('reassembles a tool call from fragmented argument deltas (live capture shape)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"city"' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: ': "Paris"}' } }] } }] }, - { choices: [{ delta: { content: '' }, finish_reason: 'tool_calls' }], usage: { prompt_tokens: 28, completion_tokens: 6 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: '{"city"' }, - { type: 'tool-call-delta', index: 0, id: 'call_00_x', name: 'get_weather', argumentsDelta: ': "Paris"}' }, - { - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' }, - }, - { type: 'usage', usage: { inputTokens: 28, outputTokens: 6, totalTokens: 34 } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ]) - }) - - it('disambiguates parallel tool calls by wire index', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { - choices: [{ - delta: { - tool_calls: [ - { index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '{}' } }, - { index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } }, - ], - }, - }], - }, - { choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - const ends = chunks.filter(chunk => chunk.type === 'block-end') - expect(ends).toEqual([ - { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{}' } }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{}' } }, - ]) - }) - - it('interleaves text and tool-call blocks with distinct indices', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'Checking.' } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - const starts = chunks.filter(chunk => chunk.type === 'block-start') - expect(starts).toEqual([ - { type: 'block-start', index: 0, blockType: 'text' }, - { type: 'block-start', index: 1, blockType: 'tool-call' }, - ]) - }) -}) - -describe('translate: finish and usage handling', () => { - it('takes usage from a trailing usage-only chunk (docs shape)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'x' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: null }, - { choices: [], usage: { prompt_tokens: 9, completion_tokens: 1 } }, - DONE, - ))) - expect(chunks.at(-2)).toEqual({ type: 'usage', usage: { inputTokens: 9, outputTokens: 1, totalTokens: 10 } }) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('last usage wins when both attached and trailing arrive', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }, - { choices: [], usage: { prompt_tokens: 2, completion_tokens: 2 } }, - DONE, - ))) - const usage = chunks.find(chunk => chunk.type === 'usage') - expect(usage).toEqual({ type: 'usage', usage: { inputTokens: 2, outputTokens: 2, totalTokens: 4 } }) - }) - - it('defaults to finish stop when no finish_reason ever arrives', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: 'x' } }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('omits the usage chunk when none arrived', async () => { - const chunks = await collect(translate(feed(firstChunk, DONE))) - expect(chunks.some(chunk => chunk.type === 'usage')).toBe(false) - }) - - it('handles chunks with no choices at all', async () => { - const chunks = await collect(translate(feed({}, DONE))) - expect(chunks).toEqual([{ - type: 'finish', - reason: { - kind: 'error', - failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, - }, - }]) - }) - - it('classifies an explicit stop with no opened blocks as EMPTY_RESPONSE, after usage', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 7, completion_tokens: 0 } }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'usage', usage: { inputTokens: 7, outputTokens: 0, totalTokens: 7 } }, - { - type: 'finish', - reason: { - kind: 'error', - failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE }, - }, - }, - ]) - }) - - it('keeps a reasoning-only stream a successful stop (any opened block counts)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { content: null, reasoning_content: 'mull' } }] }, - { choices: [{ delta: {}, finish_reason: 'stop' }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } }) - }) - - it('leaves non-stop finishes unclassified even with no opened blocks', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: {}, finish_reason: 'length' }] }, - DONE, - ))) - expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'max-tokens' } }) - }) -}) - -describe('translate: errors', () => { - it('throws MALFORMED_RESPONSE for invalid JSON payloads', async () => { - await expect(collect(translate(feed('{bad json')))).rejects.toThrow(LlmError) - await expect(collect(translate(feed('{bad json')))).rejects.toThrow(/malformed SSE payload/) - }) - - it('throws STREAM_CLOSED when the payload source ends without DONE', async () => { - await expect(collect(translate(feed(firstChunk)))).rejects.toThrow(/without \[DONE\]/) - }) -}) - -describe('mapFinishReason', () => { - it.each([ - ['stop', { kind: 'stop' }], - ['tool_calls', { kind: 'tool-calls' }], - ['length', { kind: 'max-tokens' }], - ])('maps %s', (wire, expected) => { - expect(mapFinishReason(wire)).toEqual(expected) - }) - - it.each(['content_filter', 'insufficient_system_resource', 'mystery_reason'])( - 'maps %s to an error kind with the wire code', - (wire) => { - expect(mapFinishReason(wire)).toEqual({ - kind: 'error', - failure: { message: `model stopped: ${wire}`, code: wire.toUpperCase() }, - }) - }, - ) -}) - -describe('mapUsage', () => { - it('maps the full live-capture shape', () => { - expect(mapUsage({ - prompt_tokens: 283, - completion_tokens: 69, - total_tokens: 352, - prompt_cache_hit_tokens: 256, - prompt_cache_miss_tokens: 27, - prompt_tokens_details: { cached_tokens: 256 }, - completion_tokens_details: { reasoning_tokens: 24 }, - })).toEqual({ - // 283 wire prompt_tokens minus the 256 cached → 27 uncached input - // (TokenUsage counts are disjoint). - inputTokens: 27, - outputTokens: 69, - totalTokens: 352, - cacheReadTokens: 256, - reasoningTokens: 24, - }) - }) - - it('falls back to prompt_cache_hit_tokens when details are absent', () => { - expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2, prompt_cache_hit_tokens: 8 })) - .toEqual({ inputTokens: 2, outputTokens: 2, totalTokens: 12, cacheReadTokens: 8 }) - }) - - it('reconstructs an exact total when the wire omits it', () => { - expect(mapUsage({ prompt_tokens: 10, completion_tokens: 2 })) - .toEqual({ inputTokens: 10, outputTokens: 2, totalTokens: 12 }) - }) - - it.each([ - ['contradictory total', { prompt_tokens: 10, completion_tokens: 2, total_tokens: 99 }], - ['negative prompt', { prompt_tokens: -1, completion_tokens: 2 }], - ['fractional prompt', { prompt_tokens: 1.5, completion_tokens: 2 }], - ['negative completion', { prompt_tokens: 2, completion_tokens: -1 }], - ['fractional completion', { prompt_tokens: 2, completion_tokens: 1.5 }], - ['unsafe aggregate', { prompt_tokens: Number.MAX_SAFE_INTEGER, completion_tokens: 1 }], - ])('omits the exact total for %s without changing existing buckets', (_name, wire) => { - expect(mapUsage(wire)).toEqual({ - inputTokens: wire.prompt_tokens, - outputTokens: wire.completion_tokens, - }) - }) -}) - -describe('translate: defensive tool-call branches', () => { - it('handles deltas that never carry id or name (empty-string fallbacks)', async () => { - const chunks = await collect(translate(feed( - firstChunk, - // Hypothetical lenient wire: argument fragments with no id/name at all. - { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks).toEqual([ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: '', argumentsDelta: '{}' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: '', name: '', arguments: '{}' } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ]) - }) - - it('handles tool_call deltas with a function object but no arguments field', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'f' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', name: 'f', argumentsDelta: '' }) - }) - - it('handles tool_call deltas with no function object at all', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c' }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks[1]).toEqual({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' }) - }) -}) - -describe('translate: tool-call identity across deltas', () => { - it('keeps the established identity when continuation deltas re-send it empty', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_00_x', type: 'function', function: { name: 'get_weather', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: '', type: 'function', function: { name: '', arguments: '{"city"' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: '', type: 'function', function: { name: '', arguments: ': "Paris"}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_00_x', name: 'get_weather', arguments: '{"city": "Paris"}' }, - }]) - }) - - it('keeps the established identity when continuation deltas re-send it null', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: null, function: { name: null, arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_1', name: 'Glob', arguments: '{}' }, - }]) - }) - - it('re-sending the same non-empty identity does not duplicate it', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '' } }] } }] }, - { choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'Glob', arguments: '{}' } }] } }] }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([{ - type: 'block-end', - index: 0, - block: { type: 'tool-call', id: 'call_1', name: 'Glob', arguments: '{}' }, - }]) - }) - - it('maintains each parallel call identity separately under empty continuation deltas', async () => { - const chunks = await collect(translate(feed( - firstChunk, - { - choices: [{ - delta: { - tool_calls: [ - { index: 0, id: 'a', type: 'function', function: { name: 'one', arguments: '' } }, - { index: 1, id: 'b', type: 'function', function: { name: 'two', arguments: '' } }, - ], - }, - }], - }, - { - choices: [{ - delta: { - tool_calls: [ - { index: 1, id: '', function: { name: '', arguments: '{"b":1}' } }, - { index: 0, id: '', function: { name: '', arguments: '{"a":1}' } }, - ], - }, - }], - }, - { choices: [{ delta: {}, finish_reason: 'tool_calls' }] }, - DONE, - ))) - expect(chunks.filter(chunk => chunk.type === 'block-end')).toEqual([ - { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'a', name: 'one', arguments: '{"a":1}' } }, - { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'b', name: 'two', arguments: '{"b":1}' } }, - ]) - }) -}) diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index ddff23e060..989a921e69 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import { DeepSeekFileId } from '../src/common/file-id.ts' -import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/common/upload-index.ts' +import { DeepSeekFileId } from '../src/file-id.ts' +import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`) const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`) diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index de8cd86532..2787a1ab7d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -24,7 +24,6 @@ async function harness(_model: string, config: Partial = {} providers: { deepseek: { ...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY }, - baseURL: LlmDeepSeek.PUBLIC_BASE_URL, ...config, }, }, diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index a502f07c50..6322a88f27 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -39,7 +39,6 @@ async function harness( const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(LlmDeepSeek, { - protocol: 'chat-completions', baseURL, streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, retryPolicy: { @@ -196,7 +195,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } }, + data: { reason: { kind: 'error', error: { message: 'DeepSeek Messages stream ended before message_stop', code: 'STREAM_CLOSED' } } }, }) }) @@ -242,7 +241,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } }, }) if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { - expect(end.data.reason.error.message).toContain('DeepSeek API request to') + expect(end.data.reason.error.message).toBe('DeepSeek Messages transport failed') } }) }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 6fac0fb24a..dbc3bd15e7 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3 -README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc +README.md: 04de24592379dcb0d815f3b033ca2f9f22e2ff6a +README.zh.md: bee35d4a0b62dd61a61b435dfc4eb27b62cdce69 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index cdda682ceb..04de245923 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -127,7 +127,7 @@ File detection reads current content, including tool-role result content, on eve Read these pages when the package-level contract is not enough. They move from the shared types to the concrete adapters, the retry executor, and the measurement service. - [LLM streaming subsystem](../../../docs/subsystems/llm-streaming.md) — the message and block types, compact Assistant stream records, the `StreamChunk` protocol, and the adapter contract. -- [llm-deepseek adapter](../llm-deepseek/README.md) — the direct DeepSeek chat-completions implementation. +- [llm-deepseek adapter](../llm-deepseek/README.md) — the direct DeepSeek Messages implementation. - [llm-pi-ai adapter](../llm-pi-ai/README.md) — the pi-ai-backed multi-provider implementation. - [llm-retry](../llm-retry/README.md) — the retry executor that re-runs failed model requests. - [Token meter](../token-meter/README.md) — replay-aware request and context pressure measurement. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 8c679ecc69..bee35d4a0b 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -127,7 +127,7 @@ for await (const chunk of ctx.llm.stream({ 当包级约定不够用时阅读以下页面。它们从共享类型逐步进入具体适配器、重试执行器与计量服务。 - [LLM(大语言模型)流式子系统](../../../docs/subsystems/llm-streaming.zh.md)——消息与块类型、紧凑的 Assistant 流记录、`StreamChunk` 协议与适配器约定。 -- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——DeepSeek chat-completions 直连实现。 +- [llm-deepseek 适配器](../llm-deepseek/README.zh.md)——DeepSeek Messages 直连实现。 - [llm-pi-ai 适配器](../llm-pi-ai/README.zh.md)——基于 pi-ai 的多提供方实现。 - [llm-retry](../llm-retry/README.zh.md)——重跑失败模型请求的重试执行器。 - [Token 计量](../token-meter/README.zh.md)——具备回放感知的请求与上下文压力测量。 diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 0fb2eafad1..10a1a431e4 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -647,6 +647,9 @@ describe('HarnessClient', () => { const inject = (method: string, params: Record): void => { (client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params }) } + const unknownChild = { type: 'subagent/catalog', seq: 0, time: 1, + data: { version: 1, childId: 'unreadable-child', childCreatedAt: 1, mode: 'unknown' } } + inject('session.event', { sessionId: 'root', event: unknownChild }) inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' }) inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' }) inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } }) @@ -658,6 +661,7 @@ describe('HarnessClient', () => { inject('subagent.started', { parentSessionId: '', childSessionId: 'x' }) inject('subagent.finished', { childSessionId: 'root' }) + expect(await tree.next()).toEqual({ method: 'session.event', params: { sessionId: 'root', event: unknownChild } }) expect((await tree.next()).method).toBe('subagent.started') expect((await tree.next()).method).toBe('subagent.started') expect((await tree.next()).params.sessionId).toBe('grandchild') diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 12dda386ed..467e3ca59e 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 0295894abf6b64fdd471512e4316b7acf7ec8d08 -README.zh.md: 3de6004f390550d811b111244dfa3df55343139c +README.md: 593b065c50e0e3b2eecf0c88c6aaa547a51aaf6b +README.zh.md: 26809e369330b9f06520a9a97d5a815ec46d0a65 diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 0295894abf..593b065c50 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -144,17 +144,17 @@ There is no recursive source search. Captured request text, assistant replay sta |---|---| | One descriptor with version 1 | Require string provider and label; derive `mode: 'continuable'`. | | One descriptor with version 2 or 3 | Require string provider; use its mode and optional label under catalog rules. | -| Zero descriptors, or an unsupported descriptor version | May retain an existing parent entry; cannot create a missing entry. | -| More than one own descriptor | Retain an existing parent entry without mode/label comparison; do not create a missing entry. | +| Zero descriptors, or an unsupported descriptor version | Retain an existing parent entry; otherwise append unknown-mode membership. | +| More than one own descriptor | Retain an existing parent entry without mode/label comparison; otherwise append unknown-mode membership. | | Existing own parent entry | Retain it and its extensions; require matching child creation time and mode/label from exactly one supported own descriptor, when available. | | Missing own parent entry with complete supported evidence | Append a version-0 catalog fact with child id, creation time, mode, and optional label. | -| Missing own parent entry without complete evidence | Preserve the parent without inventing a catalog entry. | +| Missing own parent entry without complete evidence | Append a version-1 `subagent/catalog` with header identity and unknown mode, without inventing a label. | -Catalog version 0 requires string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot`, and a string label for continuable mode; a present one-shot label must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. +Catalog versions 0 and 1 require string `childId`, nonnegative safe-integer `childCreatedAt`, mode `continuable` or `one-shot` (version 1 additionally accepts `unknown`), and a string label for continuable mode; a present label in any mode must also be a string. Duplicate own child ids are refused. Existing entries without a corresponding retained child remain in the parent. Descriptor collection does not restore a child's old continuation composition or recover deleted children from tool arguments. Unknown-mode entries retain header identity without asserting a supported child descriptor. The stage considers parent catalog records only after the final inherited cut. Every inherited marker discards earlier catalog candidates without interpreting their payloads. Missing entries append after all source events, sorted by creation time then child id, with dense new sequences. Their time is the final source event's time, or header creation time for an empty log. They neither enter the model surface nor change the inherited count. -Storage supplies the complete recognizable child set within its root and rechecks membership and physical revisions during preparation, memo reuse, and publication. Incomplete descriptor evidence only prevents that child’s backfill. Unreadable headers that prevent classifying membership, unsupported selected generations, or source drift refuse the operation. The package itself reads no files; [persistence](../session-persistence-jsonl/README.md) owns encoding, locks, cancellation, and publication. +Storage supplies recognizable direct-child evidence and rechecks membership and physical revisions during preparation, memo reuse, and publication. The JSONL provider isolates unreadable child headers, child decoding failures, and invalid descriptor fields while retaining other catalog entries; it does not migrate child catalogs during parent preparation. The converter still rejects conflicting supplied facts and changed parent history. [JSONL persistence](../session-persistence-jsonl/README.md) owns warnings, source checks, and child-local failure handling. Current V4 reads never invoke this converter and validate native catalog fields, uniqueness, and delivery ownership directly. ### Sequence references and inheritance @@ -243,7 +243,7 @@ Current common admission does not validate each user/tool/developer content bloc | `compaction/start`, `compaction/summary`, `compaction/end` | Match compaction id, source command, and the active turn context. Summary spans name exact current-surface nodes and exclude the protected head; successful completion has one summary. Inherited unfinished compactions expire at the end-seed marker. | | `compaction/prune` | Its span names exact current-surface nodes and excludes the protected head; it does not require a compaction transaction or its owner fields. | | Compact checkpoint replacement | Its `compact-checkpoint` source identifies the active compaction. | -| Native `subagent/catalog` | Validates own version-0 payloads after the inherited cut. A child id is unique except for one external pending-to-terminal update with unchanged mode, creation time, and label. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | +| Native `subagent/catalog` | Validates own version-0/version-1 payloads after the inherited cut. A child id is unique except for one external pending-to-terminal update with unchanged mode, creation time, and label. Native reads neither collect child logs nor compare their physical facts; inherited entries do not establish own membership. | | Inherited cut and delivery | Apply the marker, coordinate, and generation-ownership rules stated above. | These checks are generation-owned in [relationships.ts](src/relationships.ts). Full common message/envelope acceptance and plugin-owned message projections additionally use the installed Session; the exported V4 restorer alone is not a replacement for complete catalog restoration. @@ -334,7 +334,7 @@ The edge preserves the recorded request prefix. Provider cache availability and - **Nested historical tool results** — migration currently refuses results containing another tool-result wrapper. The original generation remains intact and no V4 successor is published. A later converter may support evidenced source cases without changing the established V4 format; the [migration cookbook](../../../docs/cookbook/adding-a-session-format-version.md#stages-and-validation) defines that distinction. - **Historical extension consumers** — prefixed message and result fields preserve JSON data without activating core fields. A consumer must explicitly understand those fields before interpreting them. - **Retained child logs required** — a parent alone cannot recover unrecorded child ids, creation times, or descriptors. Deleted children cannot be reconstructed from tool arguments; existing parent catalog records remain. -- **Missing historical catalog entries** — without exactly one supported own descriptor, an absent parent entry is not backfilled. The child log remains readable by id; current V4 reads do not rescan children to repair that omission. +- **Unknown historical modes** — without exactly one supported own descriptor, a missing parent entry records unknown mode. Current reads do not rewrite that entry; opening the child resolves available descriptor information or reports its own error. - **Storage scope** — facts cover recognizable children within the same persistence root. Cross-root import and corrupt-log repair are outside this migration. diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index 3de6004f39..26809e3693 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -144,17 +144,17 @@ V3 未知内容标签变为 `plugin:`,其他字段原样保留 |---|---| | 一个 version 1 descriptor | 要求字符串 provider 与 label;导出 `mode: 'continuable'`。 | | 一个 version 2 或 3 descriptor | 要求字符串 provider;按目录规则使用其 mode 和可选 label。 | -| 没有 descriptor,或 descriptor 版本不受支持 | 可以保留已有父目录项;不能创建缺失项。 | -| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;不创建缺失项。 | +| 没有 descriptor,或 descriptor 版本不受支持 | 保留已有父目录项;否则追加模式未知的目录项。 | +| 多个自身 descriptor | 保留已有父目录项,不比较 mode/label;否则追加模式未知的目录项。 | | 已有自身父目录项 | 保留条目及其扩展;要求子创建时间一致,并在恰好一个受支持的自身 descriptor 可用时比较其 mode/label。 | | 缺少自身父目录项,且受支持证据完整 | 追加带 child id、创建时间、mode 和可选 label 的 version-0 目录事实。 | -| 缺少自身父目录项,且证据不完整 | 保留父 Session,不凭空创建目录项。 | +| 缺少自身父目录项,且证据不完整 | 追加 version-1 `subagent/catalog`,记录 header 身份和未知模式,不编造标签。 | -Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode,以及 continuable mode 下的字符串 label;存在的 one-shot label 也必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。 +Catalog version 0 和 1 要求字符串 `childId`、非负安全整数 `childCreatedAt`、`continuable` 或 `one-shot` mode(version 1 还接受 `unknown`),以及 continuable mode 下的字符串 label;任何 mode 下存在的 label 都必须是字符串。重复的自身 child id 被拒绝。没有对应保留子日志的已有条目仍保留在父日志中。Descriptor 收集不恢复子级的旧 continuation composition,也不从工具参数恢复已删除子级。未知模式条目保留 header 身份信息,不声明子 descriptor 受支持。 Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。 -存储层提供其根目录内完整的可识别子级集合,并在准备、复用 memo 与发布时复查成员及物理版本。不完整的 descriptor 证据只阻止补填对应子 Session 的目录项。无法据此判断成员关系的不可读 header、不受支持的所选代际或源变化都会拒绝操作。本包不读取文件;[持久化层](../session-persistence-jsonl/README.zh.md)负责编码、锁、取消与发布。 +存储提供可识别的直属子证据,并在准备、memo 复用与发布时重新检查成员集合和物理修订。JSONL 提供方隔离不可读子 header、子日志解码失败和无效 descriptor 字段,同时保留其他目录项;准备父目录时不会迁移子目录。转换器仍拒绝冲突的已提供事实和发生变化的父历史。[JSONL 持久化](../session-persistence-jsonl/README.zh.md)拥有警告、来源检查与子会话错误隔离。当前 V4 读取不调用本转换器,直接校验原生目录字段、唯一性及投递归属。 ### 序号引用与继承 @@ -243,7 +243,7 @@ System image 接纳要求非空 attachment id、PNG/JPEG/WebP/GIF MIME 类 | `compaction/start`、`compaction/summary`、`compaction/end` | 匹配 compaction id、源 command 和活动 turn 上下文。Summary 区间引用精确的当前表面节点且排除 protected head;成功完成需要一个 summary。继承的未完成 compaction 在 end-seed marker 处过期。 | | `compaction/prune` | 其区间引用精确的当前表面节点且排除 protected head;它不要求存在 compaction 事务或其所有者字段。 | | Compact checkpoint 替换 | 其 `compact-checkpoint` 来源标识活动 compaction。 | -| 原生 `subagent/catalog` | 校验继承截点之后的自身 version-0 载荷。子 id 保持唯一,例外是一次外部 pending 到终态的更新,其模式、创建时间与标签必须不变。原生读取不收集子日志,也不比较其物理事实;继承条目不建立自身成员关系。 | +| 原生 `subagent/catalog` | 校验继承截点之后的自身 version-0/version-1 载荷。子 id 保持唯一,例外是一次外部 pending 到终态的更新,其模式、创建时间与标签必须不变。原生读取不收集子日志,也不比较其物理事实;继承条目不建立自身成员关系。 | | 继承截点与 delivery | 应用上文的 marker、坐标及代际归属规则。 | 这些检查由 [relationships.ts](src/relationships.ts) 按代际拥有。完整的通用消息/信封接纳与插件拥有的消息投影还使用已安装 Session;单独的导出 V4 恢复器不能替代完整 catalog 恢复。 @@ -334,7 +334,7 @@ Fork 种子构造归核心 Session 所有,不属于此迁移。原生 V4 接 - **历史嵌套工具结果**——当前迁移拒绝包含另一个 tool-result wrapper 的结果。原始代际保持完整,且不发布 V4 successor。后续转换器可以支持有证据的源数据场景,而不改变既定 V4 格式;[迁移 cookbook](../../../docs/cookbook/adding-a-session-format-version.zh.md#stages-and-validation) 定义了这一区别。 - **历史扩展消费者**——带前缀的消息与结果字段保留 JSON 数据,不激活核心字段。消费者必须明确理解这些字段后才能解释它们。 - **依赖保留的子日志**——仅凭父日志无法恢复未记录的子 id、创建时间或 descriptor。删除的子 Session 无法从工具参数恢复;已存在的父目录记录仍保留。 -- **历史目录项缺失**——没有恰好一个受支持的自身 descriptor 时,不补填父目录缺失项。子日志仍可按 id 读取;当前 V4 读取不会重新扫描子日志来补齐该条目。 +- **历史模式未知**——没有恰好一个受支持的自身 descriptor 时,缺失的父目录项记录未知模式。当前读取不重写该项;打开子会话时解析可用的 descriptor 信息,或报告该子会话的错误。 - **存储范围**——事实只覆盖同一持久化根目录内可识别的子 Session。跨根目录导入和损坏日志修复不属于此迁移。 diff --git a/packages/session/session-format-v3-to-v4/src/facts.ts b/packages/session/session-format-v3-to-v4/src/facts.ts index a08f2bda20..aa99fd32fa 100644 --- a/packages/session/session-format-v3-to-v4/src/facts.ts +++ b/packages/session/session-format-v3-to-v4/src/facts.ts @@ -6,7 +6,8 @@ import type { SessionFormatArtifact, SessionFormatJsonObject, SessionFormatJsonV /** * Collect a child's own descriptor without requiring one before its parent catalog is read. * @param artifact - validated historical child artifact with its exact inherited cut. - * @returns compact child identity and descriptor evidence for catalog completion. + * @returns compact child identity and descriptor evidence with validated discovery fields for catalog completion. + * @throws SessionFormatError when a supported own descriptor has invalid discovery fields. */ export function historicalChildCatalogSource(artifact: SessionFormatArtifact): SessionFormatJsonObject { const header = artifact.header @@ -14,10 +15,12 @@ export function historicalChildCatalogSource(artifact: SessionFormatArtifact): S throw new SessionFormatUnsupportedMigrationError('catalog migration requires a subagent child with a direct parent') } const descriptors = artifact.events.filter(event => event.type === 'subagent/descriptor' && event.seq >= artifact.inheritedEventCount) - return { + const source = { childId: header.id, childCreatedAt: header.createdAt, descriptorCount: descriptors.length, descriptor: descriptors[0]?.data ?? null, } + childCatalogFact(source) + return source } /** @@ -49,6 +52,9 @@ export function childCatalogFact(source: SessionFormatJsonObject): SessionFormat if (typeof descriptor['provider'] !== 'string') { throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} has an invalid subagent descriptor provider`) } + if (descriptor['version'] !== 1 && descriptor['mode'] !== 'continuable' && descriptor['mode'] !== 'one-shot') { + throw new SessionFormatUnsupportedMigrationError(`${childCatalogSubject(source)} has an invalid subagent descriptor mode`) + } return catalogFact({ version: 0, childId: id, childCreatedAt: source['childCreatedAt'] as number, // V1 described only continuable children and carried no mode field. @@ -64,12 +70,13 @@ export function childCatalogFact(source: SessionFormatJsonObject): SessionFormat * @returns validated catalog payload. */ export function catalogFact(value: SessionFormatJsonValue, subject = 'subagent/catalog'): SessionFormatJsonObject { - if (!isSessionFormatJsonObject(value) || value['version'] !== 0 + if (!isSessionFormatJsonObject(value) || (value['version'] !== 0 && value['version'] !== 1) || typeof value['childId'] !== 'string' - || (value['mode'] !== 'continuable' && value['mode'] !== 'one-shot') + || (value['mode'] !== 'continuable' && value['mode'] !== 'one-shot' && value['mode'] !== 'unknown') + || (value['version'] === 0 && value['mode'] === 'unknown') || (value['mode'] === 'continuable' && typeof value['label'] !== 'string') || (value['label'] !== undefined && typeof value['label'] !== 'string')) { - throw new SessionFormatError(`${subject} requires a complete version 0 catalog fact`) + throw new SessionFormatError(`${subject} requires a supported versioned catalog fact`) } sessionFormatCount(value['childCreatedAt'], 'catalog child creation time') return value diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index 3b1c1de60a..c2a28e04b2 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -122,9 +122,10 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage { } continue } - if (fact === undefined) continue - existingCatalogs.set(id, fact) - context.emitEvent({ type: 'subagent/catalog', seq: this.nextSeq++, time: this.time, data: fact }) + const entry = fact ?? { version: 1, childId: id, childCreatedAt: source['childCreatedAt'] as number, mode: 'unknown' } + existingCatalogs.set(id, entry) + context.emitEvent({ type: 'subagent/catalog', + seq: this.nextSeq++, time: this.time, data: entry }) } return cut } diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index 7b1ad7e3c0..455535ed79 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -67,14 +67,14 @@ describe('V3 parent catalog completion', () => { expect(migrate(source, [child], true).events).toEqual([ ...source, { type: 'subagent/catalog', seq: 4, time: 2, data: child }, ]) - expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('complete version 0') - expect(() => migrate([opaque], [])).toThrow('complete version 0') + expect(() => migrate([...source, { ...opaque, seq: 4 }], [], true)).toThrow('supported versioned') + expect(() => migrate([opaque], [])).toThrow('supported versioned') }) it('requires explicit corpus facts and refuses conflicting identities', () => { expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 }, sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts') - expect(() => migrate([], [{ ...child, mode: 'unknown' }])).toThrow('complete version 0') + expect(() => migrate([], [{ ...child, mode: 'invalid' }])).toThrow('supported versioned') expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts') }) @@ -88,8 +88,8 @@ describe('V3 parent catalog completion', () => { ], } expect(childCatalogFact(historicalChildCatalogSource(artifact))).toEqual(child) - expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toEqual([]) - expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toEqual([]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, inheritedEventCount: 0 })]).events).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [] })]).events).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) }) it('orders tied and differently dated children independently of directory enumeration', () => { const children = [{ ...child, childId: 'z' }, { ...child, childId: 'a' }, { ...child, childId: 'first', childCreatedAt: 1 }] @@ -114,13 +114,13 @@ describe('V3 parent catalog completion', () => { expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session') }) - it('skips unavailable descriptors while requiring complete discovery fields for new facts', () => { + it('retains unknown children while validating available descriptor fields', () => { const artifact = { header: { ...header, origin: 'subagent' as const, parentSession: 'parent' }, inheritedEventCount: 0, events: [] } expect(() => historicalChildCatalogSource({ ...artifact, header })).toThrow('direct parent') for (const data of [null, {}, { version: 4, mode: 'one-shot', provider: 'spawn' }]) { - expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toEqual([]) + expect(migrate([], [historicalChildCatalogSource({ ...artifact, events: [{ type: 'subagent/descriptor', seq: 0, time: 1, data }] })]).events).toMatchObject([{ data: { childId: 'parent', mode: 'unknown' } }]) } - for (const value of [null, { ...child, version: 1 }, { ...child, childId: null }, + for (const value of [null, { ...child, version: 2 }, { ...child, childId: null }, { ...child, label: null }, { ...child, childCreatedAt: -1 }]) { expect(() => migrate([], [value])).toThrow() } @@ -148,7 +148,8 @@ describe('V3 parent catalog completion', () => { const unavailable = { childId: 'unavailable', childCreatedAt: 1, descriptorCount: kind === 'missing' ? 0 : kind === 'multiple' ? 2 : 1, descriptor } expect(migrate([], [child, unavailable]).events).toEqual([ - { type: 'subagent/catalog', seq: 0, time: 1, data: child }, + { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 1, childId: 'unavailable', childCreatedAt: 1, mode: 'unknown' } }, + { type: 'subagent/catalog', seq: 1, time: 1, data: child }, ]) }) diff --git a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts index 2d3c447842..7142c6ab45 100644 --- a/packages/session/session-format-v3-to-v4/tests/validation.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/validation.spec.ts @@ -113,6 +113,22 @@ describe('restoreReleasedV4Artifact', () => { .toThrow(/unseeded format v4 Session has inherited events/) }) + it('retains unknown child membership and rejects invalid identities or duplicate child ids', () => { + const known = new Set([...KNOWN, 'subagent/catalog']) + const data = { version: 1, childId: 'unreadable', childCreatedAt: 2, mode: 'unknown' } + const event = { type: 'subagent/catalog', seq: 0, time: 1, data } + const valid = artifact([event]) + expect(restoreReleasedV4Artifact(valid, known)).toBe(valid) + expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: { ...data, label: 'retained' } }]), known)).not.toThrow() + for (const invalid of [null, { ...data, version: 0 }, { ...data, version: 2 }, { ...data, childId: 1 }, + { ...data, childCreatedAt: -1 }, { ...data, mode: 'invalid' }, { ...data, label: null }]) { + expect(() => restoreReleasedV4Artifact(artifact([{ ...event, data: invalid }]), known)).toThrow() + } + expect(() => restoreReleasedV4Artifact(artifact([event, { ...event, seq: 1 }]), known)).toThrow('duplicate catalog child') + expect(() => restoreReleasedV4Artifact(artifact([event, { type: 'subagent/catalog', seq: 1, time: 1, + data: { ...data, mode: 'one-shot' } }]), known)).toThrow('duplicate catalog child') + }) + it('validates only the envelope in physical mode', () => { const unknown = conversation() unknown.splice(2, 0, { type: 'campaign/unknown', seq: 0, time: 1, data: { x: 1 } }) diff --git a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts index 7bf73bc369..2a91b33f33 100644 --- a/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts +++ b/packages/session/session-log-deepseek/tests/feedback-composition.spec.ts @@ -53,7 +53,7 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged : name === '@deepseek-ai/dsh-message-feedback' ? { config: { maxNoteBytes: 1024 } } : name === '@deepseek-ai/dsh-llm-deepseek' - ? { config: { protocol: 'chat-completions', baseURL: server!.baseURL } } + ? { config: { baseURL: server!.baseURL } } : name === '@deepseek-ai/dsh-session-log-deepseek' ? { config: { enabled: true } } : {}, @@ -128,11 +128,11 @@ it('uploads freeform feedback and message put/edit/delete through the unchanged expect(session.deriveMessages()).toEqual(messages) expect(await ctx.messageFeedback.list({ sessionId: session.id })).toEqual({ ok: true, value: { items: [] } }) for (const wire of server.requests) { - expect(wire.path).toBe('/chat/completions') + expect(wire.path).toBe('/v1/messages') expect(wire.body).not.toHaveProperty('dsh_feedback') expect(wire.body).toMatchObject({ model: 'deepseek-v4-flash', messages: [ - { role: 'user', content: 'Question' }, - { role: 'assistant', content: 'Answer' }, + { role: 'user', content: [{ type: 'text', text: 'Question' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Answer' }] }, ] }) } await ctx.sessions.flush(session) diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 1589a1d5c7..4b42d7fa5e 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 96c333d9f845943ff44c8d5d539c40d07b9dc0f6 -README.zh.md: 71126dd909c92aabdee5c07d23505f708dc5e038 +README.md: 5846404961caaef7a91fb4dda562655d15fd2be9 +README.zh.md: b7dd646e57b75fe47f3683d2b230dace628f8eee diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 96c333d9f8..5846404961 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -81,7 +81,7 @@ The current-generation scanner applies the current codec owner’s structural ad `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. -Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Missing, unsupported, or multiple child descriptors prevent only that child’s catalog backfill; existing parent entries and child logs remain available. Invalid fields in exactly one supported own descriptor still report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events. +Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It finds candidate direct children from headers, reads each child's own descriptor through historical codecs, and retains compact evidence plus source revisions. It neither prepares child catalogs nor publishes child successors. Unreadable or unsupported headers, including corrupt Zstandard header frames, are omitted from discovery and `list()`. Direct access to a corrupt compressed header still rejects; header I/O and cancellation errors propagate. Child decoding and descriptor-field failures produce warnings naming the child path and retain header identity through `subagent/catalog` when the parent has no complete entry; healthy children and existing parent catalog entries remain available. Opening a damaged child still reports that child's error. Missing, unsupported, or multiple descriptors likewise produce unknown-mode membership without inventing a label. Published unknown entries remain browsable; child history reads retry the actual log and resolve its mode from a valid descriptor. Preparation rechecks membership and inspected source revisions before returning, reuse, and publication, including failed child reads so a repaired child invalidates stale preparation. Source drift retries a read open once and refuses a write open. Cancellation still aborts the operation. Current V4 opens bypass discovery and validate catalog fields, uniqueness, and current delivery ownership before exposing events. Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 71126dd909..b7dd646e57 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -81,7 +81,7 @@ kind: "package-reference" `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 -历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。子 descriptor 缺失、版本不受支持或存在多个时,只跳过该子 Session 的目录补填;已有父目录项和子日志仍可读取。恰好一个受支持的自身 descriptor 字段无效时,仍会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。 +历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:从 header 找到候选直属子 Session,通过历史编解码器逐个读取其自身 descriptor,保留紧凑证据与来源修订。此过程不准备子目录,也不发布子后继。不可读或不支持的 header(包括损坏的 Zstandard header 帧)不参与发现,也不出现在 `list()` 中。直接访问损坏的压缩 header 仍会失败;header 的 I/O 错误与取消错误继续传播。子日志解码或 descriptor 字段失败会产生带子路径的警告;父目录没有完整条目时,通过 `subagent/catalog` 保留 header 身份信息。健康子项和已有父目录项仍可使用。打开损坏子 Session 时仍报告该子会话的错误。缺失、不支持或多个 descriptor 同样生成模式未知的目录项,不编造标签。已发布的未知条目仍可浏览;读取子历史时会重试实际日志,并从有效 descriptor 确定模式。准备返回、复用与发布前会重新检查成员集合及已检查来源的修订,也包括读取失败的子日志,使修复后的子日志能够使旧准备缓存失效。来源变化时只读打开重试一次,写打开拒绝发布。取消仍会中止操作。当前 V4 打开跳过发现,并在暴露事件前校验目录字段、唯一性及当前投递归属。 历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。 diff --git a/packages/session/session-persistence-jsonl/src/catalog-migration.ts b/packages/session/session-persistence-jsonl/src/catalog-migration.ts index 77e3d8b43e..f0aefc057f 100644 --- a/packages/session/session-persistence-jsonl/src/catalog-migration.ts +++ b/packages/session/session-persistence-jsonl/src/catalog-migration.ts @@ -4,8 +4,7 @@ import { stat } from 'node:fs/promises' import { basename } from 'node:path' import { historicalSessionFormatCatalog, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog' import { historicalChildCatalogSource } from '@deepseek-ai/dsh-session-format-v3-to-v4' -import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' -import { SessionFormatError, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format' +import { SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format' import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { parseGenerationLogFilename } from './format.ts' @@ -16,7 +15,8 @@ import type { JsonlPhysicalIdentity } from './generation.ts' /** Per-parent supplemental facts and the source checks required before serving or publishing them. */ interface PreparedCatalogFacts { readonly facts: readonly SessionFormatJsonObject[] - /** @returns resolves while every decoded child still has the captured physical revision. */ + readonly failures: readonly { readonly path: string; readonly error: unknown }[] + /** @returns resolves while every inspected child still has the captured physical revision. */ validate(): Promise } @@ -26,7 +26,7 @@ interface PreparedCatalogFacts { * @param sources - header-indexed direct children in the selected source corpus. * @param compression - configured source encoding. * @param signal - cancellation forwarded through each source read. - * @returns compact facts; complete child event arrays are released after extraction. + * @returns compact facts and child-local failures; complete child event arrays are released after extraction. */ export async function prepareCatalogFacts( parentId: SessionId, @@ -35,11 +35,18 @@ export async function prepareCatalogFacts( signal: AbortSignal, ): Promise { const facts: SessionFormatJsonObject[] = [] + const failures: { path: string; error: unknown }[] = [] const witnesses: { path: string; identity: JsonlPhysicalIdentity }[] = [] for (const source of sources) { signal.throwIfAborted() const version = parseGenerationLogFilename(basename(source.path), compression) if (version === undefined) throw new SessionFormatUnsupportedMigrationError(`unrecognized historical child generation ${source.path}`) + const witness: { path: string; identity: JsonlPhysicalIdentity } = { + path: source.path, identity: await stat(source.path, { bigint: true }), + } + witnesses.push(witness) + const unavailable = { childId: source.header.id, childCreatedAt: source.header.createdAt, + descriptorCount: 0, descriptor: null, sourcePath: source.path } let restored: Awaited> try { restored = await readDecodedJsonlSource(source.path, version, compression, { @@ -48,28 +55,31 @@ export async function prepareCatalogFacts( }), }, signal) } catch (error: unknown) { - if (error instanceof SessionFormatUnsupportedMigrationError) { - throw new SessionFormatUnsupportedError( - `child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`, - { kind: 'jsonl', path: source.path }, - ) - } - if (error instanceof SessionFormatError) { - throw new SessionPersistenceCorruptionError(`child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`, { cause: error }) - } - throw error + signal.throwIfAborted() + failures.push({ path: source.path, error }) + facts.push(unavailable) + continue } + witness.identity = restored.identity const header = restored.artifact.header if (header.id !== source.header.id || header.createdAt !== source.header.createdAt || header.parentSession !== parentId || header.origin !== 'subagent' || ['cwd', 'isSeeded', 'delegationDepth', 'agentPreset'].some(key => header[key] !== source.header[key as keyof SessionHeader])) { throw new JsonlGenerationSourceChangedError(source.path) } - facts.push({ ...historicalChildCatalogSource(restored.artifact), sourcePath: source.path }) - witnesses.push({ path: source.path, identity: restored.identity }) + let fact: SessionFormatJsonObject + try { + fact = historicalChildCatalogSource(restored.artifact) + } catch (error: unknown) { + failures.push({ path: source.path, error }) + facts.push(unavailable) + continue + } + facts.push({ ...fact, sourcePath: source.path }) } return { facts, + failures, async validate() { for (const witness of witnesses) { const current = await stat(witness.path, { bigint: true }) diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 6e7e7b5628..1a89b76d29 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -629,10 +629,13 @@ class JsonlSessionPersistence extends SessionPersistence { let prepared: Awaited> let validateRelatedSources: () => Promise try { - const children = async () => (await this.listArtifacts(signal, selected.sourcePath)) + const children = async () => (await this.listArtifacts(signal)) .filter(source => source.header.origin === 'subagent' && source.header.parentSession === id) const sources = await children() const related = await prepareCatalogFacts(id, sources, this.compression, signal) + for (const failure of related.failures) { + this.ctx.logger.warn(`${this.name}: session "${id}" catalog retained a child with unknown descriptor (raw log: ${failure.path}): ${String(failure.error)}`) + } const membership = sources.map(source => source.path).sort() validateRelatedSources = async () => { const current = (await children()).map(source => source.path).sort() @@ -1052,7 +1055,6 @@ class JsonlSessionPersistence extends SessionPersistence { private async listArtifacts( signal?: AbortSignal, - migrationSource?: string, ): Promise> { signal?.throwIfAborted() await this.ensureRootEncoding() @@ -1061,26 +1063,14 @@ class JsonlSessionPersistence extends SessionPersistence { const ids = new Set() for (const selected of await this.listGenerations(signal)) { signal?.throwIfAborted() - if (migrationSource !== undefined && selected.sourceVersion > SESSION_FORMAT_VERSION) { - throw new SessionFormatUnsupportedError( - `catalog migration cannot inspect Session format v${selected.sourceVersion}; upgrade the harness (raw log: ${selected.sourcePath})`, - { kind: 'jsonl', path: selected.sourcePath }, - ) - } let header: SessionHeader | undefined try { header = await this.readGenerationHeader(selected, undefined, signal) } catch (error: unknown) { - if (error instanceof SessionFormatUnsupportedError && migrationSource === undefined) continue + if (error instanceof SessionFormatUnsupportedError || error instanceof SessionPersistenceCorruptionError) continue throw error } if (header === undefined) { - if (migrationSource !== undefined && selected.sourcePath !== migrationSource) { - throw new SessionFormatUnsupportedError( - `cannot establish complete catalog membership from an unreadable Session header (raw log: ${selected.sourcePath})`, - { kind: 'jsonl', path: selected.sourcePath }, - ) - } continue } if (ids.has(header.id)) { @@ -1416,7 +1406,7 @@ class JsonlSessionPersistence extends SessionPersistence { } } - /** Read and validate only the independently compressed header frame. */ + /** Read only the header frame; compression failures reject as corruption, while I/O and cancellation propagate. */ private async readFirstZstdLine(path: string, signal?: AbortSignal): Promise { signal?.throwIfAborted() const handle = await open(path, 'r') @@ -1432,21 +1422,21 @@ class JsonlSessionPersistence extends SessionPersistence { signal?.throwIfAborted() content = Buffer.concat([content, chunk.subarray(0, bytesRead)]) signal?.throwIfAborted() - const first = scanZstdFrames(content, 1).frames[0] - signal?.throwIfAborted() - if (first === undefined) continue - let plaintext: Buffer try { + const first = scanZstdFrames(content, 1).frames[0] + if (first === undefined) continue + const plaintext = await decompressZstdFrame(content.subarray(first.start, first.end)) signal?.throwIfAborted() - plaintext = await decompressZstdFrame(content.subarray(first.start, first.end)) + assertZstdHeaderFrame(plaintext) + return plaintext.subarray(0, -1).toString('utf8') } catch (error) { /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ if (signal?.aborted) signal.throwIfAborted() - throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error }) + throw new SessionPersistenceCorruptionError( + `corrupt Zstandard session log: header frame failed validation: ${String(error)} (raw log: ${path})`, + { cause: error }, + ) } - signal?.throwIfAborted() - assertZstdHeaderFrame(plaintext) - return plaintext.subarray(0, -1).toString('utf8') } } finally { await handle.close() diff --git a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts index af86cdbfe6..6abb14c0db 100644 --- a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -14,6 +14,7 @@ const roots: string[] = [] const contexts: Context[] = [] afterEach(async () => { + vi.restoreAllMocks() await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) @@ -57,16 +58,36 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('wrong Session') }) - it('does not publish incomplete membership after a child header becomes unreadable', async () => { - const f = await fixture() - const path = await f.write('child', [], true) - const broken = '{broken header\n' - await writeFile(path, compression === 'none' ? broken : await compressZstdFrame(broken)) - await expect(f.read()).rejects.toThrow(path) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(path) - expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) - }) + it.each(compression === 'none' ? ['json'] as const : ['json', 'magic', 'checksum', 'lines'] as const)( + 'opens the parent when another Session header has corrupt %s', async (damage) => { + const f = await fixture() + await f.write('child', [f.descriptor], true) + const path = await f.write('unrelated', []) + let damaged: Buffer + if (damage === 'json') { + damaged = compression === 'none' ? Buffer.from('{broken header\n') : await compressZstdFrame('{broken header\n') + } else if (damage === 'lines') { + damaged = await compressZstdFrame('{}\n{}\n') + } else { + damaged = await readFile(path) + const offset = damage === 'magic' ? 0 : damaged.length - 1 + damaged[offset] = damaged[offset]! ^ 0xFF + } + await writeFile(path, damaged) + expect((await f.ctx.sessionPersistence.list()).map(row => row.header.id).sort()).toEqual(['child', f.parent]) + const expected = [{ type: 'subagent/catalog', data: { childId: 'child', mode: 'continuable', label: 'old child' } }] + expect(await f.read()).toMatchObject(expected) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + try { expect((await writer.read()).events).toMatchObject(expected) } finally { await writer.close() } + expect(await readFile(generationLogPath(f.root, undefined, f.parent, 4, compression))).not.toHaveLength(0) + for (const access of ['read', 'write'] as const) { + await expect(f.ctx.sessionPersistence.open(SessionId('unrelated'), access)).rejects.toThrow() + } + expect(await readFile(path)).toEqual(damaged) + await expect(readFile(generationLogPath(f.root, undefined, SessionId('unrelated'), 4, compression))) + .rejects.toMatchObject({ code: 'ENOENT' }) + }, + ) it('invalidates lightweight revisions on child membership and contents while retaining stable tokens', async () => { const f = await fixture() @@ -114,34 +135,35 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).not.toBe(removed) }) - it('reports a corrupt child body at the child location', async () => { + it('isolates a corrupt child catalog and reports its path', async () => { const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn') const catalog = { type: 'subagent/catalog', seq: 0, time: 1, data: { version: 0, childId: 'grandchild', childCreatedAt: 3, mode: 'one-shot' } } const path = await f.write('child', [catalog, { ...catalog, seq: 1 }], true, 4) - await expect(f.read()).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) - await expect(f.read()).rejects.toThrow(path) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(path)) + await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) }) - it.each(['read', 'write'] as const)('locates physical child JSON corruption during %s access', async (access) => { + it.each(['read', 'write'] as const)('isolates malformed child JSON during parent %s access', async (access) => { const f = await fixture() const childPath = await f.write('child', [], true) const first = JSON.stringify({ ...f.header, id: 'child', origin: 'subagent', parentSession: f.parent, delegationDepth: 1 }) + '\n' - // A sealed turn makes the malformed row corruption rather than a recoverable tail. const body = '{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }) + '\n' await writeFile(childPath, compression === 'none' ? first + body : Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)])) - const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) - const original = await Promise.all([readFile(parentPath), readFile(childPath)]) - const error: unknown = await f.ctx.sessionPersistence.open(f.parent, access).catch((error: unknown) => error) - expect(error).toBeInstanceOf(SessionPersistenceCorruptionError) - expect((error as Error).message).toContain(childPath) - expect((error as Error).message).not.toContain(parentPath) - expect((error as Error).message).toContain('row 1 is not valid JSON') - expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) - expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) + const original = await readFile(childPath) + await f.write('healthy', [f.descriptor], true) + const parent = await f.ctx.sessionPersistence.open(f.parent, access) + try { expect((await parent.read()).events).toMatchObject([ + { type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }, + { type: 'subagent/catalog', data: { childId: 'healthy', mode: 'continuable' } }, + ]) } finally { await parent.close() } + await expect(f.ctx.sessionPersistence.open(SessionId('child'), access)).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) + expect(await readFile(childPath)).toEqual(original) + await expect(readFile(generationLogPath(f.root, undefined, SessionId('child'), 4, compression))).rejects.toMatchObject({ code: 'ENOENT' }) }) it('preserves opaque inherited catalogs through read preparation and write publication', async () => { @@ -165,21 +187,20 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', expect(await readFile(parentPath)).toEqual(original) }) - it.skipIf(compression !== 'zstd')('locates invalid compressed child frames at the child path', async () => { + it.skipIf(compression !== 'zstd')('isolates invalid compressed child frames', async () => { const f = await fixture() const childPath = await f.write('child', [], true) await writeFile(childPath, Buffer.concat([await readFile(childPath), Buffer.alloc(8)])) - const error: unknown = await f.read().catch((error: unknown) => error) - expect(error).toBeInstanceOf(SessionPersistenceCorruptionError) - expect((error as Error).message).toContain(childPath) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) }) - it('keeps a complete parent catalog when a published child never writes a descriptor', async () => { + it.each([false, true])('keeps a complete parent catalog when child metadata is unavailable (corrupt=%s)', async (corrupt) => { const f = await fixture() const catalog = { type: 'subagent/catalog', seq: 0, time: 2, data: { version: 0, childId: 'child', childCreatedAt: 2, mode: 'one-shot', label: 'failed startup' } } const parentPath = await f.write(f.parent, [catalog]) - const childPath = await f.write('child', [], true) + const childPath = await f.write('child', corrupt ? [{ type: 'future/required', seq: 0, time: 2, data: {} }] : [], true) const original = await Promise.all([readFile(parentPath), readFile(childPath)]) expect(await f.read()).toEqual([catalog]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') @@ -208,10 +229,10 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const childPath = await f.write('child', events, true) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) const original = await Promise.all([readFile(parentPath), readFile(childPath)]) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') - try { expect((await writer.read()).events).toEqual([]) } finally { await writer.close() } - expect(await f.read()).toEqual([]) + try { expect((await writer.read()).events).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) } finally { await writer.close() } + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') try { expect((await child.read()).events).toEqual(events) } finally { await child.close() } expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) @@ -219,35 +240,37 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', .toEqual(compression === 'none' ? ['session.v3.jsonl', 'session.v4.jsonl'] : ['session.v3.jsonl.zstd', 'session.v4.jsonl.zstd']) }) - it.each([false, true])('refuses an opaque future generation before publishing membership (prepared=%s)', async (prepared) => { + it.each([false, true])('omits an unsupported child generation (prepared=%s)', async (prepared) => { const f = await fixture() await f.write('child', [f.descriptor], true) if (prepared) expect(await f.read()).toHaveLength(1) await f.write('child', [], true, 5) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) + expect(await f.read()).toEqual([]) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + await writer.close() + await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) }) - it('requires a readable corpus even when an unknown header cannot identify its parent', async () => { + it('opens the parent independently of an unrelated future generation', async () => { const f = await fixture() await f.write('future-root', [], false, 5) expect((await f.ctx.sessionPersistence.list()).map(row => row.header.id)).toEqual([f.parent]) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) + expect(await f.read()).toEqual([]) }) - it('preserves unsupported child decoding errors instead of reporting parent corruption', async () => { + it('retains unknown child identity without migrating unsupported contents', async () => { const f = await fixture() - const path = await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - await expect(f.read()).rejects.toThrow(path) + await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + await expect(f.ctx.sessionPersistence.open(SessionId('child'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) }) - it('locates incomplete historical descriptor fields at their child source', async () => { + it('retains unknown child identity and reports invalid descriptor fields', async () => { const f = await fixture() + const warn = vi.spyOn(f.ctx.logger, 'warn') const path = await f.write('child', [{ ...f.descriptor, data: { version: 2, provider: 'spawn', mode: 'unknown' } }], true) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - await expect(f.read()).rejects.toThrow(path) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(path)) }) it.each(['read', 'write'] as const)('rejects duplicate own catalogs through a current %s open', async (access) => { @@ -281,7 +304,7 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', it('recollects newly available discovery fields before publishing a prepared parent', async () => { const f = await fixture() const childPath = await f.write('child', [], true) - expect(await f.read()).toEqual([]) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) await f.write('child', [f.descriptor], true) await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(childPath) const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) @@ -377,4 +400,48 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', const reopened = await ctx.sessionPersistence.open(parent, 'read') try { expect((await reopened.read()).events).toEqual(expected) } finally { await reopened.close() } }) + + it('recollects a repaired child before publishing a prepared parent', async () => { + const f = await fixture() + await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', mode: 'unknown' } }]) + await f.write('child', [f.descriptor], true) + expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child' } }]) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + await writer.close() + }) + + it('keeps published unknown membership while a repaired child opens independently', async () => { + const f = await fixture() + await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + await writer.close() + const current = generationLogPath(f.root, undefined, f.parent, 4, compression) + const published = await readFile(current) + await f.write('child', [f.descriptor], true) + const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') + try { expect((await child.read()).events).toEqual([f.descriptor]) } finally { await child.close() } + expect(await f.read()).toMatchObject([{ data: { childId: 'child', mode: 'unknown' } }]) + expect(await readFile(current)).toEqual(published) + }) + + it('completes only the opened parent catalog and defers its child catalog', async () => { + const f = await fixture() + const childPath = await f.write('child', [f.descriptor], true) + const grandchildPath = await f.write('grandchild', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) + // Reparent the physical fixture so only opening the child can inspect this damaged log. + const first = JSON.stringify({ ...f.header, id: 'grandchild', origin: 'subagent', parentSession: 'child', createdAt: 3, delegationDepth: 2 }) + '\n' + const body = JSON.stringify({ type: 'future/required', seq: 0, time: 3, data: {} }) + '\n' + await writeFile(grandchildPath, compression === 'none' ? first + body : Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)])) + const warn = vi.spyOn(f.ctx.logger, 'warn') + const source = await readFile(childPath) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + try { expect((await writer.read()).events).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child' } }]) } finally { await writer.close() } + expect(warn).not.toHaveBeenCalled() + expect(await readFile(childPath)).toEqual(source) + await expect(readFile(generationLogPath(f.root, undefined, SessionId('child'), 4, compression))).rejects.toMatchObject({ code: 'ENOENT' }) + const child = await f.ctx.sessionPersistence.open(SessionId('child'), 'write') + await child.close() + expect(warn).toHaveBeenCalledWith(expect.stringContaining(grandchildPath)) + }) }) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 4ec279ac61..f7b2f98628 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -782,7 +782,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { expect((await readAll(ctx.sessionPersistence, header.id)).events).toEqual([...oneTurnLog(), ...secondTurn]) }) - it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => { + it('skips unreadable compressed headers during discovery while rejecting targeted reads', async () => { const root = await freshRoot() for (const [id, content] of [ ['empty', Buffer.alloc(0)], @@ -803,7 +803,7 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { JSON.stringify({ type: 'turn/start' }), '', ].join('\n'))) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/first frame is not exactly one header line/) + expect(await ctx.sessionPersistence.list()).toEqual([]) await expect(ctx.sessionPersistence.open(twoLinesId, 'read')) .rejects.toThrow(/first frame is not exactly one header line/) }) @@ -824,7 +824,11 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { .rejects.toThrow(/empty or header-less Zstandard session log/) await expect(ctx.sessionPersistence.open(SessionId('empty-header'), 'read')) .rejects.toThrow(/first frame is not exactly one header line/) - await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header frame failed validation/) + expect(await ctx.sessionPersistence.list()).toEqual([]) + await expect(ctx.sessionPersistence.stat(SessionId('bad-checksum'))) + .rejects.toThrow(/header frame failed validation/) + await expect(ctx.sessionPersistence.open(SessionId('bad-checksum'), 'read')) + .rejects.toThrow(/frame at byte 0 failed validation/) }) }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index df4de5ce8b..08bbe3cdd1 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: ba28144421d2abdf5efb9ae786a02aecc56f0344 -README.zh.md: 762d93b881b864019cef0e478e804596776a4ec5 +README.md: 9424d0e71212aae455e72f1552b80af6504df811 +README.zh.md: e9760d94361a4d7efe1c0467d01c20eb340aa77b diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index ba28144421..9424d0e712 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -110,7 +110,7 @@ The service validates requested capabilities before creation. Local providers su The manager reserves child identity and capacity, materializes a local Agent or external execution, and accepts the initial task. Local `result` becomes ready after Agent activity, pending input, and owned descendants finish, with activity revalidated after the final Session flush. The manager publishes this result while closing admission, before releasing the local handle. External results can become ready before process cleanup finishes. Closing admission and disposing the handle prevent late work from entering a released Agent. Parent delivery emits its notice after settlement; caller delivery leaves collection to the awaiting workflow. Headless hosts repeat `agent.whenIdle()` and `waitForChildren(agent)` until no child work remains, so completion notices can produce the final parent answer. -Successful local and external creation appends a `subagent/catalog` fact to the parent Session. External entries have no child Session and carry only their last recorded outcome. Terminal updates replace the same catalog row; pending does not imply a live process. Complete results go to the caller or the parent completion notice. Projections exclude inherited facts. Direct lists read one parent projection; descendant lists enumerate Session-backed children through corpus identity. +Successful local and external creation appends a `subagent/catalog` fact to the parent Session. External entries have no child Session and carry only their last recorded outcome. Terminal updates replace the same catalog row; pending does not imply a live process. Complete results go to the caller or the parent completion notice. Projections exclude inherited facts. Direct lists read one parent projection; descendant lists enumerate Session-backed children through corpus identity. Catalog payload v0 records known modes; v1 also accepts unknown mode. Readers support both versions. Historical migration appends a v1 `subagent/catalog` from a readable child header when its descriptor is unavailable; normal creation retains v0. Its `mode: 'unknown'` projection keeps the child visible without claiming continuation support; an existing complete entry remains authoritative. ### Ownership and invariants diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 762d93b881..e9760d9436 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -110,7 +110,7 @@ kind: "package-reference" 管理器预留子级身份与容量,创建本地 Agent 或外部执行,并接受初始任务。本地 Agent 活动、待处理输入及所拥有的后代均结束后,管理器在最终 Session flush 后重新验证活动状态,使 `result` 就绪。管理器在关闭准入时发布结果,随后释放本地句柄。外部结果可以早于进程清理完成而就绪。关闭准入并释放句柄可防止迟到工作进入已释放的 Agent。父级投递在结算后发送通知;调用方投递由等待中的工作流收集。Headless 宿主交替等待 `agent.whenIdle()` 与 `waitForChildren(agent)`,直至没有子级工作,让完成通知可驱动父级生成最终答案。 -本地与外部创建成功时,都向父 Session 追加 `subagent/catalog` 事实。外部条目没有子 Session,只携带最近记录的结果状态。终态更新替换同一目录条目;pending 不表示进程正在运行。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影;后代列表通过语料中的身份枚举具有 Session 的子级。 +本地与外部创建成功时,都向父 Session 追加 `subagent/catalog` 事实。外部条目没有子 Session,只携带最近记录的结果状态。终态更新替换同一目录条目;pending 不表示进程正在运行。完整结果交给调用方或父级完成通知。投影排除继承事实。直接列表读取一个父级投影;后代列表通过语料中的身份枚举具有 Session 的子级。Catalog 载荷 v0 记录已知模式,v1 还接受未知模式,读取器支持两版。历史迁移在 descriptor 不可用时根据可读子 header 追加 v1 `subagent/catalog`;正常创建保留 v0。其 `mode: 'unknown'` 投影让子会话保持可见,但不表示支持继续执行;已有完整条目仍具有权威性。 ### 所有权与不变式 diff --git a/packages/subagent/subagent/src/catalog.ts b/packages/subagent/subagent/src/catalog.ts index 3dc337ee31..1c0d4f9031 100644 --- a/packages/subagent/subagent/src/catalog.ts +++ b/packages/subagent/subagent/src/catalog.ts @@ -17,24 +17,27 @@ import type { import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { ExternalSubagentStatus, SubagentCatalogEntry } from './projection-types.ts' -/** Current payload version for `subagent/catalog` events. */ +/** Catalog payload version emitted by live child creation. */ export const SUBAGENT_CATALOG_VERSION = 0 -/** One complete parent-owned catalog fact. */ +type KnownCatalogMode = + | { readonly mode: 'one-shot'; readonly label?: string; readonly external?: ExternalSubagentStatus } + | { readonly mode: 'continuable'; readonly label: string } + +/** Parent catalog v0 records known modes; v1 also retains children with unknown mode. */ export type SubagentCatalogEvent = & { - readonly version: 0 readonly childId: SessionId readonly childCreatedAt: number } & ( - | { readonly mode: 'one-shot'; readonly label?: string; readonly external?: ExternalSubagentStatus } - | { readonly mode: 'continuable'; readonly label: string } + | ({ readonly version: 0 } & KnownCatalogMode) + | ({ readonly version: 1 } & (KnownCatalogMode | { readonly mode: 'unknown'; readonly label?: string })) ) declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent @@ -49,7 +52,7 @@ export interface SubagentCatalogState { const sessionIdSchema = z.string() as unknown as z.ZodType const oneShotCatalogSchema = z.object({ - version: z.literal(SUBAGENT_CATALOG_VERSION), + version: z.union([z.literal(0), z.literal(1)]), childId: sessionIdSchema, childCreatedAt: z.number().int().nonnegative(), mode: z.literal('one-shot'), @@ -57,16 +60,18 @@ const oneShotCatalogSchema = z.object({ external: z.enum(['pending', 'completed', 'max-tokens', 'aborted', 'refusal', 'error']).optional(), }).strict() const continuableCatalogSchema = z.object({ - version: z.literal(SUBAGENT_CATALOG_VERSION), + version: z.union([z.literal(0), z.literal(1)]), childId: sessionIdSchema, childCreatedAt: z.number().int().nonnegative(), mode: z.literal('continuable'), label: z.string(), }).strict() +const unknownCatalogSchema = oneShotCatalogSchema.omit({ external: true }).extend({ version: z.literal(1), mode: z.literal('unknown') }) const eventDataSchema = z.union([ oneShotCatalogSchema, continuableCatalogSchema, -]) as unknown as z.ZodType + unknownCatalogSchema, +]) as z.ZodType const viewSchema = z.array(z.union([ oneShotCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ id: sessionIdSchema, @@ -76,7 +81,11 @@ const viewSchema = z.array(z.union([ id: sessionIdSchema, createdAt: continuableCatalogSchema.shape.childCreatedAt, }), -])) as unknown as z.ZodType + unknownCatalogSchema.omit({ version: true, childId: true, childCreatedAt: true }).extend({ + id: sessionIdSchema, + createdAt: unknownCatalogSchema.shape.childCreatedAt, + }), +])) as z.ZodType const stateSchema: z.ZodType = z.object({ inheritedEventCount: z.number().int().nonnegative() as unknown as z.ZodType, head: chunkedListSchema(eventDataSchema).optional(), @@ -89,7 +98,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } /** - * Materialize direct children from their parent's successful creation facts. + * Materialize complete and unknown-mode child identities from parent catalog events. * @param state - parent catalog fold state. * @returns current direct-child rows in parent catalog event order. */ @@ -111,7 +120,7 @@ export const subagentCatalogProjectionDefinition = { if (event.type !== 'subagent/catalog' || event.seq < state.inheritedEventCount) return state return { ...state, head: appendChunkedList(state.head, eventDataSchema.parse(event.data)) } }, - stateVersion: 3, + stateVersion: 4, wire: { viewSchema, view: subagentCatalogEntries }, } satisfies ProjectionDefinition<'subagentCatalog', SubagentCatalogState> diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index bf20c73274..eb772bf6c2 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -73,7 +73,7 @@ export type SubagentListEntry = readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } -/** Durable parent/child address that selects subagent transport in the client. */ +/** Durable parent/child browsing address; unknown mode is resolved when child history is read. */ export type SubagentAddress = & { readonly parentSessionId: SessionId @@ -82,6 +82,7 @@ export type SubagentAddress = & ( | { readonly mode: 'one-shot' } | { readonly mode: 'continuable' } + | { readonly mode: 'unknown' } ) /** One human message addressed to a continuable direct child. */ diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index 200171e7c6..05b93e113a 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -18,6 +18,7 @@ export type SubagentCatalogEntry = & ( | { readonly mode: 'one-shot'; readonly label?: string; readonly external?: ExternalSubagentStatus } | { readonly mode: 'continuable'; readonly label: string } + | { readonly mode: 'unknown'; readonly label?: string } ) /** Durable active-turn timing for one descriptor-backed child session. */ diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index ee7636044f..cc4d4e3908 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -102,11 +102,26 @@ describe('subagent catalog projection', () => { .toEqual(Array.from({ length: 130 }, (_, i) => SessionId(`child-${i}`))) }) + it.each([0, 1] as const)('reads known catalog modes at payload version %s', (version) => { + const events: SessionEvent[] = [ + { type: 'subagent/catalog', seq: SessionSeq(0), time: 0, + data: { version, childId: SessionId('once'), childCreatedAt: 1, mode: 'one-shot' } }, + { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, + data: { version, childId: SessionId('ongoing'), childCreatedAt: 2, mode: 'continuable', label: 'child' } }, + ] + expect(subagentCatalogProjectionDefinition.wire.view(fold(events))).toEqual([ + { id: SessionId('once'), createdAt: 1, mode: 'one-shot' }, + { id: SessionId('ongoing'), createdAt: 2, mode: 'continuable', label: 'child' }, + ]) + }) + it('validates recursive chunk state and materializes every label variant in event order', () => { const events: SessionEvent[] = [ fact(0, 'child-b', 1, { mode: 'one-shot' }), fact(1, 'child-a', 1, { mode: 'one-shot', label: 'once' }), fact(2, 'child-d', 3, { mode: 'continuable', label: 'later' }), + { type: 'subagent/catalog', seq: SessionSeq(3), time: 0, + data: { version: 1, childId: SessionId('unreadable'), childCreatedAt: 4, mode: 'unknown' } }, ] const state = fold(events) @@ -121,6 +136,7 @@ describe('subagent catalog projection', () => { { id: SessionId('child-b'), createdAt: 1, mode: 'one-shot' }, { id: SessionId('child-a'), createdAt: 1, mode: 'one-shot', label: 'once' }, { id: SessionId('child-d'), createdAt: 3, mode: 'continuable', label: 'later' }, + { id: SessionId('unreadable'), createdAt: 4, mode: 'unknown' }, ]) }) @@ -140,6 +156,9 @@ describe('subagent catalog projection', () => { }) it.each([ + { version: 0, childId: 'child', childCreatedAt: 0, mode: 'unknown' }, + { version: 1, childId: 'child', childCreatedAt: 0, mode: 'unknown', external: 'pending' }, + { version: 1, childId: 'child', childCreatedAt: 0, mode: 'continuable', label: 'child', external: 'pending' }, { version: 9, childId: 'child', childCreatedAt: 0, mode: 'one-shot' }, { version: 0, childId: 'child', childCreatedAt: 0, mode: 'continuable' }, { version: 0, childId: 'child', childCreatedAt: -1, mode: 'one-shot' }, @@ -162,15 +181,20 @@ describe('subagent catalog projection', () => { describe('external catalog membership', () => { - it('updates one external leaf in place and restores its terminal summary', () => { + it.each([0, 1] as const)('updates external leaves alongside unknown children at payload version %s', (version) => { const start = fact(0, 'external', 1, { mode: 'one-shot', label: 'Review' }) - const pending = { ...start, data: { ...start.data, mode: 'one-shot' as const, external: 'pending' as const } } + const pending = { ...start, data: { ...start.data, version, mode: 'one-shot' as const, external: 'pending' as const } } const end = { ...pending, seq: SessionSeq(2), data: { ...pending.data, external: 'completed' as const } } - const state = fold([pending, fact(1, 'local', 2, { mode: 'continuable', label: 'Implement' }), end]) + const unknown: SessionEvent<'subagent/catalog'> = { + type: 'subagent/catalog', seq: SessionSeq(3), time: 0, + data: { version: 1, childId: SessionId('unreadable'), childCreatedAt: 3, mode: 'unknown' }, + } + const state = fold([pending, fact(1, 'local', 2, { mode: 'continuable', label: 'Implement' }), end, unknown]) const restored = subagentCatalogProjectionDefinition.stateSchema.parse(JSON.parse(JSON.stringify(state))) expect(subagentCatalogProjectionDefinition.wire.view(restored)).toEqual([ { id: SessionId('external'), createdAt: 1, mode: 'one-shot', label: 'Review', external: 'completed' }, { id: SessionId('local'), createdAt: 2, mode: 'continuable', label: 'Implement' }, + { id: SessionId('unreadable'), createdAt: 3, mode: 'unknown' }, ]) expect(subagentCatalogProjectionDefinition.wire.view(fold([pending]))[0]).toMatchObject({ external: 'pending' }) expect(subagentCatalogProjectionDefinition.wire.viewSchema.safeParse([{ id: 'external', createdAt: 1, mode: 'one-shot', external: 'invalid' }]).success).toBe(false) diff --git a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts index 22d59cb9b0..e5437afafc 100644 --- a/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts +++ b/packages/subagent/subagent/tests/continuation-messages-adapter.spec.ts @@ -11,7 +11,7 @@ import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-dee import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' -import { end, MODEL, server, sse, start } from '../../../llm/llm-deepseek/tests/messages/helpers.ts' +import { end, MODEL, server, sse, start } from '../../../llm/llm-deepseek/tests/helpers.ts' import SubagentRuntime, { type SubagentRunEndInfo } from '../src/index.ts' import { loadStoredSession } from './persistence-helpers.ts' diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index d37d28f763..6a6ad363c6 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: 1e9a9aeabe8b27f67557216f354d31a6ccf9d594 -README.zh.md: 27f3f5447f2845f5dbcbc928d62222cb0fbefe84 +README.md: b4197ffc750ec585e4df1b9da9af1731558bfc99 +README.zh.md: 087d464ba900965d23b09d1ff9f67b9259f08933 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 1e9a9aeabe..b4197ffc75 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -89,7 +89,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 27f3f5447f..087d464ba9 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -89,7 +89,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 diff --git a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts index 14284556f5..bd945ab43f 100644 --- a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts +++ b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts @@ -17,7 +17,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { evaluate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { exactPackageSpecifier, parseDshClient } from '@deepseek-ai/dsh-client-modules/client' import * as yaml from 'js-yaml' @@ -41,11 +41,16 @@ interface BundleLayer { * Compose the browser roster of `bundles`, applied in order. * @param bundles - bundle package names in application order. * @param anchor - file whose package resolution locates the bundles; default this package. + * @param disabledContext - optional Loader evaluation scope for trusted `disabled` expressions. * @returns the roster in composition order, one row per package. * @throws {Error} when a bundle, its patch file, or an enabled row's package does not resolve, when the patch list - * is not a list or does not apply as written, or when a browser row's `disabled` is a `!!js` expression. + * is not a list or does not apply as written, or when a browser row has an unevaluated `disabled` expression. */ -export function bundleRoster(bundles: readonly string[], anchor: string = fileURLToPath(import.meta.url)): ClientRoster { +export function bundleRoster( + bundles: readonly string[], + anchor: string = fileURLToPath(import.meta.url), + disabledContext?: object, +): ClientRoster { const layers = bundles.map(name => readLayer(name, anchor)) const entries = applyEntryPatches([], layers.flatMap(layer => layer.patches), (message: string, ...args: unknown[]) => { throw new Error(`client-test-runtime: bundle patch ${describe(message, args)}`) @@ -55,7 +60,9 @@ export function bundleRoster(bundles: readonly string[], anchor: string = fileUR const seen = new Set() for (const { entry, disabled } of flattenGroups(entries)) { const name = exactPackageSpecifier(entry.name) - if (name === undefined || disabled === true || seen.has(name)) continue + if (name === undefined || disabled.includes(true) || seen.has(name)) continue + if (disabledContext !== undefined + && disabled.some(value => isJsExpr(value) && Boolean(evaluate(disabledContext, value.__jsExpr)))) continue seen.add(name) const manifestPath = locateManifest(anchors, name) if (manifestPath === undefined) { @@ -67,7 +74,8 @@ export function bundleRoster(bundles: readonly string[], anchor: string = fileUR } const declaration = parseDshClient(name, manifest.dsh?.client) if (declaration === undefined || declaration.platform !== 'web') continue - if (disabled !== undefined && disabled !== null && typeof disabled !== 'boolean') { + if (disabled.some(value => value !== undefined && value !== null && typeof value !== 'boolean' + && !(disabledContext !== undefined && isJsExpr(value)))) { throw new Error(`client-test-runtime: browser row ${name} has a \`disabled\` value this reader cannot evaluate (a !!js expression)`) } rows.push({ name, inject: declaration.inject ?? [], immediately: declaration.immediately === true }) @@ -86,24 +94,23 @@ function readLayer(bundle: string, anchor: string): BundleLayer { return { manifestPath, patches: parsed as PatchOptions[] } } -/** One Loader row with the `disabled` value that governs it: its own, or the nearest enclosing group's when that is set. */ +/** One Loader row with its ancestor and own disable conditions, in outer-to-inner order. */ interface FlatEntry { readonly entry: EntryOptions - readonly disabled: unknown + readonly disabled: readonly unknown[] } /** * Rows in Loader order with groups descended, as the Loader loads them: a group is never a plugin itself, and a group's * `disabled` disables every row beneath it. * @param entries - composed entries, possibly nested. - * @param inherited - the enclosing group's `disabled` when set. + * @param inherited - the enclosing groups' disable conditions. * @returns the plugin rows. */ -function flattenGroups(entries: readonly EntryOptions[], inherited?: unknown): FlatEntry[] { +function flattenGroups(entries: readonly EntryOptions[], inherited: readonly unknown[] = []): FlatEntry[] { const rows: FlatEntry[] = [] for (const entry of entries) { - const own = (entry as { disabled?: unknown }).disabled - const disabled = inherited !== undefined && inherited !== null && inherited !== false ? inherited : own + const disabled = [...inherited, (entry as { disabled?: unknown }).disabled] if (entry.group === true && Array.isArray(entry.config)) { rows.push(...flattenGroups(entry.config as EntryOptions[], disabled)) continue @@ -134,5 +141,9 @@ function describe(message: string, args: readonly unknown[]): string { return message.replace(/%C/g, () => JSON.stringify(args[index++])) } -/** The `web` profile's browser roster, composed from its bundles at import. */ -export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES) +const webProfileServices: Readonly> = { profileContext: { name: 'web' } } + +/** The `web` profile's browser roster, composed with its profile name and no business Host services. */ +export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES, undefined, { + get: (name: string) => webProfileServices[name], +}) diff --git a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts index e73ce00818..a1fa3f6191 100644 --- a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts @@ -7,6 +7,11 @@ import { afterAll, describe, expect, it } from 'vitest' import { MODULES_PACKAGE } from '../src/assembly/modules.ts' import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts' +function profileScope(name: string) { + const services: Record = { profileContext: { name } } + return { get: (key: string) => services[key] } +} + describe('webApp (the real web profile)', () => { it('composes dsh-base then dsh-web-app: unique names, inject edges on roster rows or platform seed words', () => { expect(WEB_PROFILE_BUNDLES).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) @@ -15,7 +20,10 @@ describe('webApp (the real web profile)', () => { const known = new Set([...names, ...Object.keys(getStaticModules())]) const dangling = webApp.rows.flatMap(row => row.inject.filter(target => !known.has(target)).map(target => `${row.name} -> ${target}`)) expect(dangling).toEqual([]) - expect(bundleRoster(WEB_PROFILE_BUNDLES).rows).toEqual(webApp.rows) + expect(bundleRoster(WEB_PROFILE_BUNDLES, undefined, profileScope('web')).rows).toEqual(webApp.rows) + expect(names).not.toContain('@deepseek-ai/dsh-client-ui-sidebar-browser') + expect(bundleRoster(WEB_PROFILE_BUNDLES, undefined, profileScope('desktop')).rows.map(row => row.name)) + .toContain('@deepseek-ai/dsh-client-ui-sidebar-browser') }) it('keeps browser rows with their declarations and drops Host-only, disabled, and subpath rows', () => { @@ -154,6 +162,84 @@ describe('bundleRoster on a scratch installation', () => { expect(() => scratch.roster(['@t/maybe-bundle'])).toThrow('browser row @t/maybe has a `disabled` value this reader cannot evaluate') }) + it('evaluates profile conditions before deduplication and skips ignored row expressions', () => { + scratch.web('@t/conditional') + scratch.bundle('@t/conditional-bundle', ` +- insert: + - id: ignored + name: ./ignored.mjs + disabled: !!js missingIgnoredValue + - id: first + name: '@t/conditional' + disabled: !!js ctx.get('profileContext').name !== 'desktop' + - id: second + name: '@t/conditional' + disabled: false +`) + expect(bundleRoster(['@t/conditional-bundle'], scratch.anchor, profileScope('web')).rows.map(row => row.name)) + .toEqual(['@t/conditional']) + }) + + it('combines ancestor and child conditions without evaluating disabled subtrees', () => { + scratch.web('@t/conditional-on') + scratch.web('@t/conditional-off') + scratch.bundle('@t/conditional-groups', ` +- insert: + - id: parent-off + name: cordis:group + group: true + disabled: !!js true + config: + - id: unreachable + name: '@t/not-installed' + disabled: !!js missingInactiveValue + - id: literal-off + name: cordis:group + group: true + disabled: true + config: + - id: also-unreachable + name: '@t/not-installed-either' + disabled: !!js missingInactiveValue + - id: parent-on + name: cordis:group + group: true + disabled: !!js false + config: + - id: child-off + name: '@t/conditional-off' + disabled: true + - id: nested-on + name: cordis:group + group: true + disabled: !!js false + config: + - id: child-on + name: '@t/conditional-on' + disabled: null +`) + expect(bundleRoster(['@t/conditional-groups'], scratch.anchor, {}).rows.map(row => row.name)) + .toEqual(['@t/conditional-on']) + }) + + it('propagates evaluation failures and rejects unsupported literal flags with a scope', () => { + scratch.web('@t/bad-condition') + scratch.bundle('@t/bad-expression', ` +- insert: + - id: bad + name: '@t/bad-condition' + disabled: !!js missingConditionValue +`) + expect(() => bundleRoster(['@t/bad-expression'], scratch.anchor, {})).toThrow('missingConditionValue') + scratch.bundle('@t/bad-literal', ` +- insert: + - id: bad + name: '@t/bad-condition' + disabled: not-a-boolean +`) + expect(() => bundleRoster(['@t/bad-literal'], scratch.anchor, {})).toThrow('disabled') + }) + it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => { expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from') scratch.pkg('@t/no-patch', { dsh: {} }) diff --git a/packages/test-support/llm-mock-server/README.i18n.yaml b/packages/test-support/llm-mock-server/README.i18n.yaml index 10ca40b376..22fcf568b1 100644 --- a/packages/test-support/llm-mock-server/README.i18n.yaml +++ b/packages/test-support/llm-mock-server/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/llm-mock-server/README.md -README.md: be311d9c83bcd35ad5bd0c003f6e95861b6d6976 -README.zh.md: 7a022131d9ef09d9ef89c91cae418f093f77aca1 +README.md: 2f83cf66c02728820a4f8aa6f14b1a091d092626 +README.zh.md: ad85853fb4a20c3ccdb444508da8032c18d7538a diff --git a/packages/test-support/llm-mock-server/README.md b/packages/test-support/llm-mock-server/README.md index be311d9c83..2f83cf66c0 100644 --- a/packages/test-support/llm-mock-server/README.md +++ b/packages/test-support/llm-mock-server/README.md @@ -1,5 +1,5 @@ --- -description: "Scriptable OpenAI-compatible fault server for testing LLM adapters and recovery policy without a provider key, for test authors and demos." +description: "Scriptable Messages-compatible fault server for testing LLM adapters and recovery policy without a provider key, for test authors and demos." kind: "package-library" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package gives tests and demos a scriptable OpenAI-compatible HTTP/SSE endpoint, so they can exercise model-provider failures and successes without a provider key. Each accepted `/chat/completions` request consumes the next scripted behavior, including resets, stalls, malformed chunks, rate limits, server errors, completions, and tool calls. Test authors can run it with `pnpm run mock:llm` or call `startMockLlmServer`, which returns captured requests for assertions. Seeded `random` behavior supports reproducible mixed-failure stress runs. +This package gives tests and demos a scriptable Messages-compatible HTTP/SSE endpoint, so they can exercise model-provider failures and successes without a provider key. Each accepted `/v1/messages` request consumes the next scripted behavior, including resets, stalls, malformed chunks, rate limits, server errors, completions, and tool calls. Test authors can run it with `pnpm run mock:llm` or call `startMockLlmServer`, which returns captured requests for assertions. Seeded `random` behavior supports reproducible mixed-failure stress runs. ## Table of Contents @@ -39,7 +39,7 @@ pnpm run mock:llm \ --partial-text "discard this half" ``` -Point the shipping DeepSeek adapter at the server; it appends `/chat/completions` to the configured base: +Point the shipping DeepSeek adapter at the server; it sends requests to `/messages` beneath the server's `/v1` root: ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ @@ -88,13 +88,13 @@ Omitting `--seed` generates one and prints it in the `ready` record. `--random-w ### Timing and content controls -The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer `; omission accepts any token. +The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `x-api-key: `; omission accepts any token. ### What can go wrong - **The script runs out** — exhaustion returns a structured HTTP 500; set `--repeat-last` or lengthen the sequence when a run needs more requests. - **Random weights without a positive concrete behavior are rejected** — every entry must name an existing behavior and at least one must carry positive weight. -- **Invalid requests do not consume the script** — wrong methods, paths, bearer tokens, and malformed JSON get ordinary 4xx responses, so a misconfigured client can burn retries without advancing the sequence. +- **Invalid requests do not consume the script** — wrong methods, paths, API keys, and malformed JSON get ordinary 4xx responses, so a misconfigured client can burn retries without advancing the sequence. ----- @@ -108,7 +108,7 @@ This section explains the design of the server; the observable behavior is fully ### Design -The server is built on one rule: each accepted chat-completions request consumes exactly one behavior from an arrival-ordered FIFO cursor, and the server never retries or interprets harness policy. Validation happens before the cursor advances — only a `POST` whose path ends in `/chat/completions`, with a valid bearer token when one is configured and a parseable JSON body, consumes the script; everything else receives an ordinary 4xx. `random` entries resolve at request time through a seeded PRNG over the configured weights, so a run is reproducible from its printed seed. +The server is built on one rule: each accepted Messages request consumes exactly one behavior from an arrival-ordered FIFO cursor, and the server never retries or interprets harness policy. Validation happens before the cursor advances — only a `POST` whose path ends in `/v1/messages`, with a valid API key when one is configured and a parseable JSON body, consumes the script; everything else receives an ordinary 4xx. `random` entries resolve at request time through a seeded PRNG over the configured weights, so a run is reproducible from its printed seed. ### Source map diff --git a/packages/test-support/llm-mock-server/README.zh.md b/packages/test-support/llm-mock-server/README.zh.md index 7a022131d9..ad85853fb4 100644 --- a/packages/test-support/llm-mock-server/README.zh.md +++ b/packages/test-support/llm-mock-server/README.zh.md @@ -1,5 +1,5 @@ --- -description: "用于在无提供方密钥的情况下测试 LLM(大语言模型)适配器与恢复策略的可通过脚本控制的 OpenAI 兼容故障服务器,面向测试作者与演示。" +description: "用于在无提供方密钥的情况下测试 LLM(大语言模型)适配器与恢复策略的可通过脚本控制的 Messages 兼容故障服务器,面向测试作者与演示。" kind: "package-library" --- @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -本包为测试与演示提供可编脚本的 OpenAI 兼容 HTTP/SSE(Server-Sent Events)端点,使其无需提供方密钥即可检验模型提供方的失败与成功。每个已接受的 `/chat/completions` 请求依次消费下一个脚本行为,包括重置、停滞、畸形分片、限流、服务器错误、补全与工具调用。测试作者可以通过 `pnpm run mock:llm` 运行服务器,也可以调用 `startMockLlmServer`,后者会返回捕获的请求供断言使用。带种子的 `random` 行为支持可复现的混合故障压力运行。 +本包为测试与演示提供可编脚本的 Messages 兼容 HTTP/SSE(Server-Sent Events)端点,使其无需提供方密钥即可检验模型提供方的失败与成功。每个已接受的 `/v1/messages` 请求依次消费下一个脚本行为,包括重置、停滞、畸形分片、限流、服务器错误、补全与工具调用。测试作者可以通过 `pnpm run mock:llm` 运行服务器,也可以调用 `startMockLlmServer`,后者会返回捕获的请求供断言使用。带种子的 `random` 行为支持可复现的混合故障压力运行。 ## 目录 @@ -39,7 +39,7 @@ pnpm run mock:llm \ --partial-text "discard this half" ``` -将发布的 DeepSeek 适配器指向服务器;它会将 `/chat/completions` 追加到已配置 base: +将发布的 DeepSeek 适配器指向服务器;它会向服务器 `/v1` 根路径下的 `/messages` 发送请求: ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ @@ -88,13 +88,13 @@ pnpm run mock:llm \ ### 时序与内容控制 -CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 与 `--tool-arguments`。毫秒延迟是 Node 定时器范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的 `apiKey` 会精确验证 `Authorization: Bearer `;省略时接受任何 token。 +CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-size`、`--chunk-delay-ms`、`--disconnect-delay-ms`、`--retry-after-ms`、`--request-id`、`--tool-name` 与 `--tool-arguments`。毫秒延迟是 Node 定时器范围内的有界整数;`retryAfterMs` 还必须为正数。库接受相同的 camel-case 选项。可选的 `apiKey` 会精确验证 `x-api-key: `;省略时接受任何 token。 ### 可能出什么问题 - **脚本耗尽**——耗尽时返回结构化 HTTP 500;当一次运行需要更多请求时设置 `--repeat-last` 或加长序列。 - **没有正权重具体行为的随机权重会被拒绝**——每个条目都必须命名现有行为,且至少一个条目带正权重。 -- **无效请求不消费脚本**——错误方法、路径、Bearer token 与畸形 JSON 会收到普通 4xx 响应,因此配置错误的客户端可能耗尽重试却不推进序列。 +- **无效请求不消费脚本**——错误方法、路径、API key 与畸形 JSON 会收到普通 4xx 响应,因此配置错误的客户端可能耗尽重试却不推进序列。 ----- @@ -108,7 +108,7 @@ CLI 公开 `--success-text`、`--partial-text`、`--reasoning-text`、`--chunk-s ### 设计 -服务器建立在一个规则之上:每个已接受的 chat-completions 请求从按到达顺序排列的 FIFO 游标消费恰好一个行为,服务器从不重试或解读 harness 策略。校验先于游标推进——只有 `POST` 且路径以 `/chat/completions` 结尾、配置密钥时携带有效 Bearer token、且 JSON 正文可解析的请求才消费脚本;其余请求都收到普通 4xx。`random` 条目在请求时通过带种子的 PRNG 按配置权重解析,因此一次运行可由其打印出的种子复现。 +服务器建立在一个规则之上:每个已接受的 Messages 请求从按到达顺序排列的 FIFO 游标消费恰好一个行为,服务器从不重试或解读 harness 策略。校验先于游标推进——只有 `POST` 且路径以 `/v1/messages` 结尾、配置密钥时携带有效 API key、且 JSON 正文可解析的请求才消费脚本;其余请求都收到普通 4xx。`random` 条目在请求时通过带种子的 PRNG 按配置权重解析,因此一次运行可由其打印出的种子复现。 ### 源码地图 diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 6bed08aa80..225c056215 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", - "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", + "description": "Scriptable Messages HTTP/SSE fault server for LLM recovery tests", "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" diff --git a/packages/test-support/llm-mock-server/src/index.ts b/packages/test-support/llm-mock-server/src/index.ts index b07a2bb09b..9ddab9a2cc 100644 --- a/packages/test-support/llm-mock-server/src/index.ts +++ b/packages/test-support/llm-mock-server/src/index.ts @@ -1,6 +1,6 @@ /** - * Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and - * semantic-empty LLM recovery tests. Each accepted chat-completions request + * Scriptable Messages HTTP/SSE server for transport, protocol, and + * semantic-empty LLM recovery tests. Each accepted Messages request * consumes one behavior; the server never retries or interprets harness policy. * * @module @deepseek-ai/dsh-llm-mock-server @@ -95,7 +95,7 @@ export type MockLlmServerEvent = /** Captured wire request and its final server-side outcome. */ export interface MockLlmRequestRecord { - /** One-based accepted chat-completions request number. */ + /** One-based accepted Messages request number. */ readonly attempt: number /** Script entry consumed for this request before random resolution. */ readonly scriptBehavior: MockLlmBehavior | 'script_exhausted' @@ -119,7 +119,7 @@ export interface MockLlmServerOptions { readonly host?: string /** TCP port; zero requests an OS-assigned port. */ readonly port?: number - /** Optional exact bearer token; omission accepts any authorization header. */ + /** Optional exact API key; omission accepts any x-api-key header. */ readonly apiKey?: string /** Ordered request behaviors; exhaustion fails loud unless `repeatLast` is true. */ readonly sequence: readonly MockLlmBehavior[] @@ -155,7 +155,7 @@ export interface MockLlmServerOptions { /** Running mock server and captured request state. */ export interface MockLlmServer { - /** Base URL without `/v1`; both root and `/v1` chat-completions paths are accepted. */ + /** Base URL without `/v1`; the endpoint is `/v1/messages`. */ readonly baseURL: string /** Actual bound port, including an OS-assigned value. */ readonly port: number @@ -325,7 +325,7 @@ function writeSse(record: MockLlmRequestRecord, response: ServerResponse, payloa } function writeDone(record: MockLlmRequestRecord, response: ServerResponse): void { - writeSse(record, response, '[DONE]') + writeSse(record, response, { type: 'message_stop' }) } function finishRecord( @@ -364,10 +364,22 @@ function httpError( finishRecord(options, record, 'completed') } +function startMessage(record: MockLlmRequestRecord, response: ServerResponse): void { + writeSse(record, response, { + type: 'message_start', + message: { id: 'mock-message', type: 'message', role: 'assistant', model: 'mock-model', content: [], usage: { input_tokens: 3, output_tokens: 0 } }, + }) +} + +function startText(record: MockLlmRequestRecord, response: ServerResponse, index: number): void { + writeSse(record, response, { type: 'content_block_start', index, content_block: { type: 'text', text: '' } }) +} + function terminalChunk(reason: string, outputTokens: number): unknown { return { - choices: [{ index: 0, delta: { content: '' }, finish_reason: reason }], - usage: { prompt_tokens: 3, completion_tokens: outputTokens }, + type: 'message_delta', + delta: { stop_reason: reason, stop_sequence: null }, + usage: { output_tokens: outputTokens }, } } @@ -393,9 +405,10 @@ async function streamText( response: ServerResponse, text: string, delayMs: number, + index = 0, ): Promise { for (const chunk of splitText(text, options.chunkSize)) { - writeSse(record, response, { choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] }) + writeSse(record, response, { type: 'content_block_delta', index, delta: { type: 'text_delta', text: chunk } }) if (!await pause(delayMs, response)) return false } return true @@ -405,13 +418,16 @@ async function completeText( options: ResolvedOptions, record: MockLlmRequestRecord, response: ServerResponse, - reason: 'stop' | 'length', + reason: 'end_turn' | 'max_tokens', delayMs: number, + index = 0, ): Promise { - if (!await streamText(options, record, response, options.successText, delayMs)) { + startText(record, response, index) + if (!await streamText(options, record, response, options.successText, delayMs, index)) { finishRecord(options, record, 'client_closed') return } + writeSse(record, response, { type: 'content_block_stop', index }) writeSse(record, response, terminalChunk(reason, Array.from(options.successText).length)) writeDone(record, response) response.end() @@ -434,27 +450,11 @@ async function disconnect( function toolCallChunks(options: ResolvedOptions): readonly unknown[] { const midpoint = Math.max(1, Math.floor(options.toolArguments.length / 2)) return [ - { - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - id: 'mock-call-1', - type: 'function', - function: { name: options.toolName, arguments: options.toolArguments.slice(0, midpoint) }, - }], - }, - finish_reason: null, - }], - }, - { - choices: [{ - index: 0, - delta: { tool_calls: [{ index: 0, function: { arguments: options.toolArguments.slice(midpoint) } }] }, - finish_reason: null, - }], - }, + { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'mock-call-1', name: options.toolName, input: {} } }, + ...[options.toolArguments.slice(0, midpoint), options.toolArguments.slice(midpoint)].map(partial => ({ + type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: partial }, + })), + { type: 'content_block_stop', index: 0 }, ] } @@ -478,7 +478,8 @@ async function runBehavior( return case 'empty': openSse(response) - writeSse(record, response, terminalChunk('stop', 0)) + startMessage(record, response) + writeSse(record, response, terminalChunk('end_turn', 0)) writeDone(record, response) response.end() finishRecord(options, record, 'completed') @@ -490,18 +491,22 @@ async function runBehavior( return case 'stream_eof': openSse(response) - writeSse(record, response, { choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] }) + startMessage(record, response) response.end() finishRecord(options, record, 'completed') return case 'partial_eof': openSse(response) + startMessage(record, response) + startText(record, response, 0) await streamText(options, record, response, options.partialText, 0) response.end() finishRecord(options, record, 'completed') return case 'partial_disconnect': openSse(response) + startMessage(record, response) + startText(record, response, 0) if (!await streamText(options, record, response, options.partialText, options.chunkDelayMs)) return await disconnect(options, record, response) return @@ -518,14 +523,15 @@ async function runBehavior( return case 'malformed_event': openSse(response) - writeSse(record, response, { choices: [null] }) + writeSse(record, response, { type: 'content_block_start', index: 0, content_block: null }) writeDone(record, response) response.end() finishRecord(options, record, 'completed') return case 'wrong_content_type': openSse(response, 'application/json') - await completeText(options, record, response, 'stop', 0) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', 0) return case 'rate_limit': httpError(options, record, response, 429, 'mock rate limit', 'rate_limit') @@ -558,32 +564,37 @@ async function runBehavior( return case 'success': openSse(response) - await completeText(options, record, response, 'stop', 0) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', 0) return case 'reasoning_success': openSse(response) + startMessage(record, response) + writeSse(record, response, { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }) for (const chunk of splitText(options.reasoningText, options.chunkSize)) { - writeSse(record, response, { - choices: [{ index: 0, delta: { reasoning_content: chunk }, finish_reason: null }], - }) + writeSse(record, response, { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: chunk } }) } - await completeText(options, record, response, 'stop', 0) + writeSse(record, response, { type: 'content_block_stop', index: 0 }) + await completeText(options, record, response, 'end_turn', 0, 1) return case 'tool_call_success': openSse(response) + startMessage(record, response) for (const chunk of toolCallChunks(options)) writeSse(record, response, chunk) - writeSse(record, response, terminalChunk('tool_calls', 2)) + writeSse(record, response, terminalChunk('tool_use', 2)) writeDone(record, response) response.end() finishRecord(options, record, 'completed') return case 'max_tokens': openSse(response) - await completeText(options, record, response, 'length', 0) + startMessage(record, response) + await completeText(options, record, response, 'max_tokens', 0) return case 'slow_success': openSse(response) - await completeText(options, record, response, 'stop', options.chunkDelayMs) + startMessage(record, response) + await completeText(options, record, response, 'end_turn', options.chunkDelayMs) return } } @@ -615,9 +626,9 @@ function chooseRandomBehavior( } /** - * Start a local chat-completions server that consumes one configured behavior - * per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script; - * invalid routes, methods, authorization, and JSON receive ordinary 4xx + * Start a local Messages server that consumes one configured behavior + * per accepted request. Only a `POST` path ending in `/v1/messages` consumes the script; + * invalid routes, methods, API keys, and JSON receive ordinary 4xx * responses. Closing the handle terminates stalled connections. * * @param options - listener, script, response content, timing, and telemetry options. @@ -652,13 +663,13 @@ export async function startMockLlmServer(options: MockLlmServerOptions): Promise response.writeHead(405, { allow: 'POST' }).end() return } - if (!path.endsWith('/chat/completions')) { + if (!path.endsWith('/v1/messages')) { response.writeHead(404).end() return } - if (resolved.apiKey !== undefined && request.headers.authorization !== `Bearer ${resolved.apiKey}`) { + if (resolved.apiKey !== undefined && request.headers['x-api-key'] !== resolved.apiKey) { response.writeHead(401, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: { message: 'invalid mock bearer token', code: 'invalid_api_key' } })) + response.end(JSON.stringify({ error: { message: 'invalid mock API key', code: 'invalid_api_key' } })) return } diff --git a/packages/test-support/llm-mock-server/tests/server.spec.ts b/packages/test-support/llm-mock-server/tests/server.spec.ts index a32e5cc8b2..5d7bbfaba6 100644 --- a/packages/test-support/llm-mock-server/tests/server.spec.ts +++ b/packages/test-support/llm-mock-server/tests/server.spec.ts @@ -18,24 +18,24 @@ async function start( return server } -function chat( +function messages( server: MockLlmServer, options: { path?: string; key?: string; body?: string; signal?: AbortSignal } = {}, ): Promise { - return fetch(`${server.baseURL}${options.path ?? '/v1/chat/completions'}`, { + return fetch(`${server.baseURL}${options.path ?? '/v1/messages'}`, { method: 'POST', headers: { 'content-type': 'application/json', - ...options.key === undefined ? {} : { authorization: `Bearer ${options.key}` }, + ...options.key === undefined ? {} : { 'x-api-key': options.key }, }, body: options.body ?? JSON.stringify({ model: 'mock', messages: [], stream: true }), ...options.signal === undefined ? {} : { signal: options.signal }, }) } -function rawChat(server: MockLlmServer, chunks: readonly Buffer[]): Promise { +function rawMessages(server: MockLlmServer, chunks: readonly Buffer[]): Promise { return new Promise((resolve, reject) => { - const outgoing = request(`${server.baseURL}/v1/chat/completions`, { + const outgoing = request(`${server.baseURL}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json' }, }, (response) => { @@ -59,22 +59,22 @@ describe('mock LLM server wire behaviors', () => { onEvent: (event) => { events.push(event) }, }) - const response = await chat(server, { key: 'mock-key' }) + const response = await messages(server, { key: 'mock-key' }) const body = await response.text() expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/event-stream') - expect(body).toContain('"content":"rec"') - expect(body).toContain('"content":"ove"') - expect(body).toContain('"content":"red"') - expect(body).toContain('"finish_reason":"stop"') - expect(body).toContain('data: [DONE]') + expect(body).toContain('"text":"rec"') + expect(body).toContain('"text":"ove"') + expect(body).toContain('"text":"red"') + expect(body).toContain('"stop_reason":"end_turn"') + expect(body).toContain('"type":"message_stop"') expect(server.requests).toEqual([expect.objectContaining({ attempt: 1, behavior: 'success', - path: '/v1/chat/completions', + path: '/v1/messages', body: { model: 'mock', messages: [], stream: true }, - chunksSent: 5, + chunksSent: 8, outcome: 'completed', })]) expect(events).toEqual([ @@ -83,7 +83,7 @@ describe('mock LLM server wire behaviors', () => { attempt: 1, scriptBehavior: 'success', behavior: 'success', - path: '/v1/chat/completions', + path: '/v1/messages', }, { type: 'result', @@ -91,39 +91,39 @@ describe('mock LLM server wire behaviors', () => { scriptBehavior: 'success', behavior: 'success', outcome: 'completed', - chunksSent: 5, + chunksSent: 8, }, ]) }) - it('supports root paths and intentionally ignores telemetry observer failures', async () => { + it('supports gateway prefixes and intentionally ignores telemetry observer failures', async () => { const server = await start(['empty'], { onEvent() { throw new Error('observer failed') }, }) - const response = await chat(server, { path: '/chat/completions' }) + const response = await messages(server, { path: '/anthropic/v1/messages' }) expect(response.status).toBe(200) - expect(await response.text()).toContain('data: [DONE]') - expect(server.requests[0]).toMatchObject({ path: '/chat/completions', outcome: 'completed' }) + expect(await response.text()).toContain('"type":"message_stop"') + expect(server.requests[0]).toMatchObject({ path: '/anthropic/v1/messages', outcome: 'completed' }) }) it.each([ ['empty_body', 0, ''] as const, ['stream_eof', 1, '"role":"assistant"'] as const, - ['partial_eof', 1, 'discarded partial response'] as const, + ['partial_eof', 3, 'discarded partial response'] as const, ['malformed_json', 2, 'data: {not-json'] as const, - ['malformed_event', 2, '"choices":[null]'] as const, + ['malformed_event', 2, '"content_block":null'] as const, ])('serves %s without inventing a terminal completion', async (behavior, chunks, marker) => { const server = await start([behavior], { chunkSize: 100 }) - const response = await chat(server) + const response = await messages(server) const body = await response.text() expect(response.status).toBe(200) expect(body).toContain(marker) if (behavior !== 'malformed_json' && behavior !== 'malformed_event') { - expect(body).not.toContain('[DONE]') + expect(body).not.toContain('message_stop') } expect(server.requests[0]).toMatchObject({ behavior, chunksSent: chunks, outcome: 'completed' }) }) @@ -137,7 +137,7 @@ describe('mock LLM server wire behaviors', () => { let headersReceived = false await expect((async () => { - const response = await chat(server) + const response = await messages(server) headersReceived = true await response.text() })()).rejects.toThrow() @@ -145,7 +145,7 @@ describe('mock LLM server wire behaviors', () => { expect(headersReceived).toBe(receivesHeaders) expect(server.requests[0]).toMatchObject({ behavior, - chunksSent: behavior === 'partial_disconnect' ? 1 : 0, + chunksSent: behavior === 'partial_disconnect' ? 3 : 0, outcome: 'reset', }) }) @@ -153,7 +153,7 @@ describe('mock LLM server wire behaviors', () => { it('holds a stalled stream until the client aborts and server close remains idempotent', async () => { const server = await start(['stall']) const controller = new AbortController() - const response = await chat(server, { signal: controller.signal }) + const response = await messages(server, { signal: controller.signal }) expect(response.status).toBe(200) expect(server.requests[0]).toMatchObject({ behavior: 'stall', outcome: 'stalled' }) @@ -180,7 +180,7 @@ describe('mock LLM server wire behaviors', () => { }, }) const controller = new AbortController() - const response = await chat(server, { signal: controller.signal }) + const response = await messages(server, { signal: controller.signal }) controller.abort() await expect(response.text()).rejects.toThrow() await result.promise @@ -197,7 +197,7 @@ describe('mock LLM server wire behaviors', () => { const characterOffset = encoded.indexOf(Buffer.from('你')) expect(characterOffset).toBeGreaterThanOrEqual(0) - await rawChat(server, [ + await rawMessages(server, [ encoded.subarray(0, characterOffset + 1), encoded.subarray(characterOffset + 1), ]) @@ -209,7 +209,7 @@ describe('mock LLM server wire behaviors', () => { const server = await start(['success'], { host: '::1' }) expect(server.baseURL).toMatch(/^http:\/\/\[::1\]:\d+$/) - expect((await chat(server)).status).toBe(200) + expect((await messages(server)).status).toBe(200) }) it('emits reasoning, tool calls, max-token finishes, slow chunks, and a wrong content type', async () => { @@ -231,17 +231,17 @@ describe('mock LLM server wire behaviors', () => { const bodies: string[] = [] const contentTypes: Array = [] for (let index = 0; index < 5; index += 1) { - const response = await chat(server) + const response = await messages(server) contentTypes.push(response.headers.get('content-type')) bodies.push(await response.text()) } - expect(bodies[0]).toContain('"reasoning_content":"th"') + expect(bodies[0]).toContain('"thinking":"th"') expect(bodies[1]).toContain('"name":"lookup"') - expect(bodies[1]).toContain('"arguments":"{\\"id"') - expect(bodies[1]).toContain('"finish_reason":"tool_calls"') - expect(bodies[2]).toContain('"finish_reason":"length"') - expect(bodies[3]).toContain('"finish_reason":"stop"') + expect(bodies[1]).toContain('"partial_json":"{\\"id"') + expect(bodies[1]).toContain('"stop_reason":"tool_use"') + expect(bodies[2]).toContain('"stop_reason":"max_tokens"') + expect(bodies[3]).toContain('"stop_reason":"end_turn"') expect(contentTypes[4]).toBe('application/json') expect(server.requests).toHaveLength(5) expect(server.requests.every(record => record.outcome === 'completed')).toBe(true) @@ -257,7 +257,7 @@ describe('mock LLM server wire behaviors', () => { ['quota_exceeded', 429, 'insufficient_quota'] as const, ])('serves %s as a structured HTTP error', async (behavior, status, marker) => { const server = await start([behavior], { retryAfterMs: 1_001, requestId: 'mock-request-1' }) - const response = await chat(server) + const response = await messages(server) const body = await response.text() expect(response.status).toBe(status) @@ -270,15 +270,15 @@ describe('mock LLM server wire behaviors', () => { it('fails loud on script exhaustion and can explicitly repeat the final behavior', async () => { const exhausted = await start(['success'], { successText: 'once' }) - await (await chat(exhausted)).text() - const exhaustedResponse = await chat(exhausted) + await (await messages(exhausted)).text() + const exhaustedResponse = await messages(exhausted) expect(exhaustedResponse.status).toBe(500) expect(await exhaustedResponse.text()).toContain('mock script exhausted') expect(exhausted.requests.map(record => record.behavior)).toEqual(['success', 'script_exhausted']) const repeating = await start(['empty'], { repeatLast: true }) - await (await chat(repeating)).text() - await (await chat(repeating)).text() + await (await messages(repeating)).text() + await (await messages(repeating)).text() expect(repeating.requests.map(record => record.behavior)).toEqual(['empty', 'empty']) }) @@ -295,8 +295,8 @@ describe('mock LLM server wire behaviors', () => { running.push(first, second) for (let attempt = 0; attempt < 12; attempt += 1) { - await (await chat(first)).text() - await (await chat(second)).text() + await (await messages(first)).text() + await (await messages(second)).text() } const firstChoices = first.requests.map(record => record.behavior) @@ -307,12 +307,12 @@ describe('mock LLM server wire behaviors', () => { expect(first.requests.every(record => record.scriptBehavior === 'random')).toBe(true) }) - it('rejects invalid method, route, bearer token, and JSON without consuming the script', async () => { + it('rejects invalid method, route, API key, and JSON without consuming the script', async () => { const server = await start(['success'], { apiKey: 'expected' }) - const method = await fetch(`${server.baseURL}/v1/chat/completions`) - const route = await fetch(`${server.baseURL}/v1/other`, { method: 'POST', body: '{}' }) - const auth = await chat(server, { key: 'wrong' }) - const json = await chat(server, { key: 'expected', body: '{' }) + const method = await fetch(`${server.baseURL}/v1/messages`) + const route = await fetch(`${server.baseURL}/chat/completions`, { method: 'POST', body: '{}' }) + const auth = await messages(server, { key: 'wrong' }) + const json = await messages(server, { key: 'expected', body: '{' }) expect(method.status).toBe(405) expect(method.headers.get('allow')).toBe('POST') @@ -321,9 +321,9 @@ describe('mock LLM server wire behaviors', () => { expect(json.status).toBe(400) expect(server.requests).toHaveLength(0) - const emptyRequest = await fetch(`${server.baseURL}/v1/chat/completions`, { + const emptyRequest = await fetch(`${server.baseURL}/v1/messages`, { method: 'POST', - headers: { authorization: 'Bearer expected' }, + headers: { 'x-api-key': 'expected' }, }) expect(emptyRequest.status).toBe(200) expect(server.requests[0]?.behavior).toBe('success') diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index dee2ad6293..6aa6fb0107 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/package-manifest/README.md -README.md: 86121432ba12329e111f2c796679ae019e403d7e -README.zh.md: d4d9d8b5b7787ac9531918fb1f7084c7c8a7a8d1 +README.md: 95dbbb729792872a9d41716db4fb4f2e8ac763b5 +README.zh.md: 02c21647f085af09472cca9f8c2e1a06be4e1124 diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index 86121432ba..95dbbb7297 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -52,7 +52,7 @@ The following metadata fields are optional. Omitting them leaves the format vers | `dsh.manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. | | `engines.dsh` | Author-declared compatible DSH versions as a SemVer range, including exact prerelease versions. This field sits beside `engines.node` and `engines.npm`; an engines object may omit `dsh`. | -`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title and description fields, or a metadata diagnostic, for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. +`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title, description, an image data URL resolved from `package.json.icon`, and metadata diagnostics for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. Public composition declarations are defined in [`src/types.ts`](src/types.ts). Internal `configTrees`, `sessionFormatMigration`, and generated `moduleFallback` metadata remain owned by their image-packer, catalog, and launcher readers; the public types do not expose them. diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index d4d9d8b5b7..02c21647f0 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -52,7 +52,7 @@ const manifest: DshPackageManifest = { | `dsh.manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 | | `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围,也可填写精确的预发布版本。此字段与 `engines.node`、`engines.npm` 并列;engines 对象可省略 `dsh`。 | -`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题与描述字段,或元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 +`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题、描述、从 `package.json.icon` 解析的图片 data URL,以及元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。 diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index d6f161f97e..a028c71b02 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -12,6 +12,8 @@ export interface DshPackageManifest { version: string /** Package summary for discovery and display. */ description?: string + /** SVG, PNG, JPEG, or WebP file relative to this manifest's directory, at most 256 KiB and contained there after realpath resolution. */ + icon?: string /** Prevent npm publication, for example for local profile projects. */ private?: boolean /** Packages installed alongside this package. */ @@ -39,12 +41,14 @@ export interface DshManifest { /** Literal text or translations indexed by lowercase language id, with a required English fallback. */ export type LocalizedText = string | { readonly en: string; readonly [locale: string]: string } -/** Validated plugin display fields or a diagnostic from reading exported locale files. */ +/** Validated plugin display fields and diagnostics from exported locales, manifests, or icon files. */ export interface PluginLocalizedMeta { /** Display title; omission preserves the consumer's technical-name fallback. */ readonly title?: LocalizedText /** Display introduction after locale and package-field fallback. */ readonly description?: LocalizedText + /** Base64 image data URL read from the manifest's icon file; render as an image, not inline markup. */ + readonly icon?: string /** Unmodified local metadata diagnostic; the plugin remains manageable. */ readonly error?: string } diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 713c6c7851..f0d546d923 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-search-deepseek/README.md -README.md: 1fa8153b55e40edc869d6d332e3c35a46de0cd1b -README.zh.md: a38c1e126a23033cd40ef468f98b35d7cc56d869 +README.md: 0617204fdddb9273f75bd9ad4028026c7ffc8060 +README.zh.md: b45b449f2d379f730a6a4f341e8359ed28aaeb0b diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 1fa8153b55..0617204fdd 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -33,7 +33,7 @@ Choose this backend when a deployment wants DeepSeek's native server-side web se ### Minimal configuration -Load the web service and the provider; the key resolves from `ctx.credentials` when that service is mounted, otherwise from the process environment. The auxiliary search call has its own endpoint setting and uses the Anthropic-compatible base `https://api.deepseek.com/anthropic/v1`, with `/messages` appended. It reads `$DEEPSEEK_SEARCH_BASE_URL`, independently of the conversation adapter’s `$DEEPSEEK_BASE_URL` and protocol. +Load the web service and the provider; the key resolves from `ctx.credentials` when that service is mounted, otherwise from the process environment. The auxiliary search call has its own endpoint setting and uses the Anthropic-compatible base `https://api.deepseek.com/anthropic/v1`, with `/messages` appended. It reads `$DEEPSEEK_SEARCH_BASE_URL`, independently of the conversation adapter’s `$DEEPSEEK_BASE_URL`. ```yaml - name: '@deepseek-ai/dsh-web' diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index a38c1e126a..b45b449f2d 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 最小配置 -加载 web 服务与本提供方;密钥在已挂载 `ctx.credentials` 服务时从其解析,否则从进程环境解析。辅助搜索调用有独立的端点设置,使用 Anthropic 兼容基址 `https://api.deepseek.com/anthropic/v1`,并追加 `/messages`。它读取 `$DEEPSEEK_SEARCH_BASE_URL`,与会话适配器的 `$DEEPSEEK_BASE_URL` 和协议相互独立。 +加载 web 服务与本提供方;密钥在已挂载 `ctx.credentials` 服务时从其解析,否则从进程环境解析。辅助搜索调用有独立的端点设置,使用 Anthropic 兼容基址 `https://api.deepseek.com/anthropic/v1`,并追加 `/messages`。它读取 `$DEEPSEEK_SEARCH_BASE_URL`,与会话适配器的 `$DEEPSEEK_BASE_URL` 相互独立。 ```yaml - name: '@deepseek-ai/dsh-web' diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py index acadb495be..34c8f5b1f0 100644 --- a/python/sdk/tests/manual_sdk_agent_smoke.py +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -26,16 +26,22 @@ class MockCompletionHandler(BaseHTTPRequestHandler): body = self.rfile.read(length).decode("utf-8") self.requests.append({ "path": self.path, - "authorization": self.headers.get("authorization"), + "api_key": self.headers.get("x-api-key"), "body": json.loads(body), }) self.send_response(200) self.send_header("content-type", "text/event-stream") self.end_headers() - self.wfile.write(b'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') - self.wfile.write(b'data: {"choices":[{"delta":{"content":"SDK runtime reached the configured HTTP model endpoint."}}]}\n\n') - self.wfile.write(b'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":9}}\n\n') - self.wfile.write(b"data: [DONE]\n\n") + events = [ + {"type": "message_start", "message": {"id": "sdk-smoke", "model": "sdk-smoke-model", "usage": {"input_tokens": 7, "output_tokens": 0}}}, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "SDK runtime reached the configured HTTP model endpoint."}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 9}}, + {"type": "message_stop"}, + ] + for event in events: + self.wfile.write(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode()) def log_message(self, _format: str, *_args: object) -> None: return @@ -46,7 +52,7 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: session_root = dsh_home / "sessions" runtime_entry = repo_root / "apps/cli/src/bin.ts" server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) - thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) + thread = threading.Thread(target=server.serve_forever, name="mock-messages-server", daemon=True) thread.start() base_url = f"http://127.0.0.1:{server.server_address[1]}" @@ -86,7 +92,8 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None: assert len(MockCompletionHandler.requests) == 1 request = MockCompletionHandler.requests[0] print(json.dumps(request, ensure_ascii=False, indent=2)[:4000]) - assert request["authorization"] == "Bearer sdk-smoke-key" + assert request["path"] == "/v1/messages" + assert request["api_key"] == "sdk-smoke-key" assert request["body"]["model"] == "sdk-smoke-model" jsonl_files = sorted(session_root.rglob("*.jsonl.zstd")) diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 0d1cc64097..66b9b2665c 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -595,6 +595,15 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None: client = HarnessClient() with client.subscribe_session_notifications("main") as first: + unknown_child = { + "type": "subagent/catalog", "seq": 0, "time": 1, + "data": {"version": 1, "childId": "unreadable-child", "childCreatedAt": 1, "mode": "unknown"}, + } + client._handle_message({ + "jsonrpc": "2.0", "method": "session.event", + "params": {"sessionId": "main", "event": unknown_child}, + }) + assert first.next().payload == {"sessionId": "main", "event": unknown_child} client._handle_message({ "jsonrpc": "2.0", "method": "subagent.started", diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 0a06641c14..7d012f6440 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -164,6 +164,20 @@ describe('dsh family version coherence', () => { }) describe('package payload constraints', () => { + it.each(['./art/icon.svg', 'art/icon.svg'])('includes declared icon %s in the canonical payload', (icon) => { + expect(expectedDshPackageFiles({ icon, exports: { './locale/*.json': './locale/*.json' } })).toEqual([ + 'art/icon.svg', 'locale/*.json', 'lib/index.js', 'lib/types/**/*.d.ts', + ]) + }) + + it('accepts the Agent Team icon payload and rejects its omission', () => { + const dir = 'packages/experimental/agent-team-profile' + const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) + expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== 'icon.svg') } })) + .toEqual([expect.stringContaining('package.json files must be')]) + }) + it.each([ { exports: { './locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] }, { exports: { './search/locale/*.json': './resources/search/*.json' }, resources: ['resources/search/*.json'] }, diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 2372546421..aeb4415b97 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -90,6 +90,7 @@ export interface PackageManifest { | undefined > files?: string[] + icon?: string publishConfig?: { access?: string } repository?: { type?: string; url?: string; directory?: string } peerDependencies?: Record @@ -210,9 +211,9 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl } /** - * Compute canonical publication patterns, including explicitly exported locale JSON resources. + * Compute canonical publication patterns, including the declared icon and exported locale JSON resources. * @param manifest - workspace package manifest. - * @returns deduplicated locale targets followed by the package's runtime and declaration payloads. + * @returns the icon and deduplicated locale targets followed by runtime and declaration payloads. */ export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { const localeFiles = new Set() @@ -228,6 +229,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []), ] return [ + ...typeof manifest.icon === 'string' ? [manifest.icon.replace(/^\.\//u, '')] : [], ...[...localeFiles].sort(), 'lib/index.js', // Packages with an invariant export publish its runtime as a separate diff --git a/scripts/dev-web.spec.ts b/scripts/dev-web.spec.ts index 76dbf82860..df1274c57c 100644 --- a/scripts/dev-web.spec.ts +++ b/scripts/dev-web.spec.ts @@ -1,15 +1,112 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import type { TsdownBundle } from 'tsdown' import { writeClientBuildRecord } from './client-build-environment.ts' import { devWebBuildEnvironment, discoverLibraryDirs, discoverPluginDirs, + parseDevWebArguments, + StageSupervisor, watchClientPlugins, } from './dev-web.ts' +import type { StageHandle } from './dev-web.ts' + +/** A stage double that exits only on the listed signals or an explicit exit code. */ +function fakeStage(name: string, exitOn: readonly NodeJS.Signals[]): { + handle: StageHandle + signals: NodeJS.Signals[] + exit: (code: number) => void +} { + const exit = Promise.withResolvers() + const signals: NodeJS.Signals[] = [] + return { + handle: { + name, + exited: exit.promise, + kill(signal) { + signals.push(signal) + if (exitOn.includes(signal)) exit.resolve(null) + }, + }, + signals, + exit: (code) => { exit.resolve(code) }, + } +} + +describe('StageSupervisor', () => { + it('reports a stage that exits on its own', async () => { + const stale: [string, number | null][] = [] + const supervisor = new StageSupervisor((name, code) => { stale.push([name, code]) }) + const tsc = fakeStage('tsc', ['SIGTERM']) + supervisor.add(tsc.handle) + tsc.exit(2) + await tsc.handle.exited + expect(stale).toEqual([['tsc', 2]]) + }) + + it('forwards one signal, escalates survivors after the grace period, and reports none of them', async () => { + const stale: string[] = [] + const supervisor = new StageSupervisor((name) => { stale.push(name) }) + const polite = fakeStage('vite', ['SIGTERM']) + const stubborn = fakeStage('dsh web', ['SIGKILL']) + supervisor.add(polite.handle) + supervisor.add(stubborn.handle) + await supervisor.stop({ signal: 'SIGTERM', graceMs: 20 }) + expect(polite.signals).toEqual(['SIGTERM']) + expect(stubborn.signals).toEqual(['SIGTERM', 'SIGKILL']) + expect(stale).toEqual([]) + }) + + it('waits for stages the terminal already interrupted before signaling survivors', async () => { + const supervisor = new StageSupervisor(() => { throw new Error('unexpected stale report') }) + const interrupted = fakeStage('tsdown', []) + const survivor = fakeStage('dsh web', ['SIGTERM']) + supervisor.add(interrupted.handle) + supervisor.add(survivor.handle) + const stopping = supervisor.stop({ graceMs: 20 }) + interrupted.exit(130) + await stopping + expect(interrupted.signals).toEqual([]) + expect(survivor.signals).toEqual(['SIGTERM']) + }) +}) + +describe('parseDevWebArguments', () => { + it('builds, serves, and watches natively by default', () => { + expect(parseDevWebArguments([])).toEqual({ skipBuild: false, serve: true, pollInterval: undefined, appArgs: [] }) + }) + + it('reads the polling interval with its 500ms default', () => { + expect(parseDevWebArguments(['--poll']).pollInterval).toBe(500) + expect(parseDevWebArguments(['--poll=250']).pollInterval).toBe(250) + }) + + it.each(['--poll=abc', '--poll=0', '--poll=-5', '--poll=1.5'])('rejects the polling interval %s', (flag) => { + expect(() => parseDevWebArguments([flag])).toThrow(`invalid --poll interval "${flag}"`) + }) + + it('separates its own flags from the arguments forwarded to dsh web', () => { + expect(parseDevWebArguments(['--skip-build', '--poll', '--no-open', '--port', '8080'])).toEqual({ + skipBuild: true, serve: true, pollInterval: 500, appArgs: ['--no-open', '--port', '8080'], + }) + }) + + it('runs only the rebuild watchers with --no-serve', () => { + expect(parseDevWebArguments(['--no-serve', '--skip-build'])).toMatchObject({ serve: false, skipBuild: true }) + }) + + it('ignores the separator pnpm run forwards verbatim', () => { + expect(parseDevWebArguments(['--', '--no-open']).appArgs).toEqual(['--no-open']) + expect(parseDevWebArguments(['--no-serve', '--'])).toMatchObject({ serve: false, appArgs: [] }) + }) + + it('rejects dsh web arguments when no server is started', () => { + expect(() => parseDevWebArguments(['--no-serve', '--no-open'])).toThrow('--no-serve leaves no dsh web process for --no-open') + }) +}) it('samples one local environment at startup without validating watcher outputs', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-')) diff --git a/scripts/dev-web.ts b/scripts/dev-web.ts index 35500eca13..dd282d773c 100644 --- a/scripts/dev-web.ts +++ b/scripts/dev-web.ts @@ -1,28 +1,38 @@ /** - * Watch-build for the web dev loop: rebuilds every artifact the browser reads - * from a source edit. Reload signaling is not this script's business — the host - * webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames - * itself (`dsh web`), so any process that rewrites `lib/client.js` files - * triggers reloads; this script is merely the convenient way to keep them all - * rebuilt on source change. + * The Web development loop: build once, serve through `dsh web`, and keep every + * browser-side artifact rebuilt on source edits, in one terminal. * - * Three stages, because the compile shell links built lib products rather than - * sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib - * entries are that emit, not `src`), tsdown bundles `lib/index.js` and - * `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web` - * serves. A missing stage does not fail — it silently shows the previous - * artifact, so an edit appears to do nothing. + * Stages, in order: `pnpm run build` (skipped by `--skip-build`), three + * long-lived watchers, then `dsh web` (skipped by `--no-serve`). The watchers + * exist because the compile shell links built lib products rather than + * sources: `tsc -b tsconfig.client.json --watch` emits `lib/types` (the tsdown + * lib entries are that emit, not `src`), tsdown watch bundles `lib/index.js` + * and `lib/client.js`, and `vite build --watch` rewrites `apps/web/dist`, which + * `dsh web` serves. A missing watcher does not fail — it silently shows the + * previous artifact, so an edit appears to do nothing; any stage exiting on its + * own therefore stops the loop with exit code 1. * - * MUST NOT run concurrently with `pnpm run build`: both write the same - * `lib/` and `apps/web/dist/` trees. + * Reload signaling is not this script's business — the host webserver + * stat-polls the bundles it serves and broadcasts `rebuilt` frames itself, so + * any process that rewrites `lib/client.js` files triggers reloads. * - * Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior - * `pnpm run build`: every stage is incremental over the previous stage's output - * and none of them bootstraps a missing tree. `--poll` switches the source - * watchers to polling (default 500ms): network mounts (weka) deliver no inotify - * events, so native watching sees the initial build only and never a source - * change. Polling has to reach tsc too — a native-watching tsc never re-emits - * `lib/types`, which strands the other two stages on stale input. + * MUST NOT run beside `pnpm run build`: both write the same `lib/` and + * `apps/web/dist/` trees. The build stage here finishes before any watcher starts. + * + * Usage: `pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]`. + * `--skip-build` requires the artifact tree from a prior complete build: every + * watcher is incremental over the previous stage's output and none of them + * bootstraps a missing tree. `--no-serve` keeps only the watchers, for a + * `dsh web` started elsewhere. `--poll` switches the source watchers to polling + * (default 500ms): network mounts (weka) deliver no inotify events, so native + * watching sees the initial build only and never a source change. Polling has + * to reach tsc too — a native-watching tsc never re-emits `lib/types`, which + * strands the other two stages on stale input. + * + * Shutdown: Ctrl+C reaches every stage through the terminal's process group, so + * the script only waits for them and escalates survivors; SIGTERM is addressed + * to this process alone and is forwarded once. The exit code is 130 after + * SIGINT, 0 after SIGTERM, and 1 when a stage exited on its own. * * Each package keeps its own tsdown.config.ts untouched: this script layers * `watch` through API-level inline config (tsdown workspace mode fills inline @@ -39,6 +49,7 @@ import { clientBuildProcessEnvironment, repositoryClientBuildEnvironment, } from './client-build-environment.ts' +import { pnpmInvocation } from './pnpm-invocation.ts' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -67,6 +78,51 @@ export function devWebBuildEnvironment( return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment)) } +/** Resolved `dev-web` command line: the script's own flags plus the arguments forwarded to `dsh web`. */ +export interface DevWebArguments { + /** Skip the complete `pnpm run build` that otherwise precedes the watchers. */ + readonly skipBuild: boolean + /** Start `dsh web`; false keeps only the rebuild watchers beside an already running server. */ + readonly serve: boolean + /** Source-watcher polling interval in milliseconds; undefined selects native watching. */ + readonly pollInterval: number | undefined + /** Arguments forwarded verbatim to `dsh web`, in order. */ + readonly appArgs: readonly string[] +} + +/** Polling interval selected by a bare `--poll`. */ +const DEFAULT_POLL_INTERVAL = 500 + +/** + * Parse the script's command line. `--skip-build`, `--no-serve`, and `--poll[=ms]` + * belong to this script; a bare `--` is dropped; every other token is forwarded + * to `dsh web`. + * @param argv - arguments after the script path. + * @returns the resolved flags and forwarded arguments. + * @throws Error when `--poll` carries a non-positive or non-integer interval, or + * when `--no-serve` leaves forwarded arguments without a `dsh web` process. + */ +export function parseDevWebArguments(argv: readonly string[]): DevWebArguments { + let skipBuild = false + let serve = true + let pollInterval: number | undefined + const appArgs: string[] = [] + for (const arg of argv) { + // `pnpm run` forwards a `--` separator verbatim; it carries no meaning here. + if (arg === '--') continue + if (arg === '--skip-build') skipBuild = true + else if (arg === '--no-serve') serve = false + else if (arg === '--poll' || arg.startsWith('--poll=')) { + pollInterval = arg === '--poll' ? DEFAULT_POLL_INTERVAL : Number(arg.slice('--poll='.length)) + if (!Number.isInteger(pollInterval) || pollInterval <= 0) throw new Error(`dev-web: invalid --poll interval "${arg}"`) + } else appArgs.push(arg) + } + if (!serve && appArgs.length > 0) { + throw new Error(`dev-web: --no-serve leaves no dsh web process for ${appArgs[0] ?? ''}`) + } + return { skipBuild, serve, pollInterval, appArgs } +} + /** * Discover the watch workspace by declaration: every packages// * whose package.json carries `dsh.client` with platform "web" is a client @@ -153,46 +209,166 @@ export async function watchClientPlugins( return bundles } -/** - * Live watcher processes to terminate when this script is interrupted. Stages - * register themselves as they start, so the set is complete from the first - * spawn: an interrupt during a later stage's startup still tears down the - * earlier ones instead of orphaning them. - */ -const stages: StageHandle[] = [] +/** One long-lived stage process as the supervisor observes it. */ +export interface StageHandle { + /** Command label used in diagnostics. */ + readonly name: string + /** Settles with the exit code, or null when a signal ended the stage. */ + readonly exited: Promise + /** + * Deliver one signal; a no-op once the stage has exited. + * @param signal - the signal to send. + */ + kill(signal: NodeJS.Signals): void +} + +/** How a requested stop reaches the stages. */ +export interface StageStop { + /** Signal sent to every live stage first; omitted when the terminal already delivered SIGINT to them. */ + readonly signal?: NodeJS.Signals | undefined + /** Milliseconds to wait for exits before escalating to SIGTERM, and again before SIGKILL. */ + readonly graceMs: number +} /** - * Spawn one watcher stage, inheriting stdio, registering it for teardown, and - * failing loud if it ever exits: a dead stage leaves the artifact chain silently - * stale, which reads as "my edit did nothing" — the one failure this script - * exists to prevent. - * @param stage - command label used in the exit diagnostic. - * @param command - executable, resolved from the workspace bin when local. + * Own the live stages: report a stage that exits before a stop was requested, + * and on a requested stop signal, wait, and escalate until every stage has exited. + */ +export class StageSupervisor { + private readonly live = new Set() + private stopping = false + + /** + * @param onStale - receives the stage name and exit code when a stage exits before a stop was requested. + */ + constructor(private readonly onStale: (stage: string, code: number | null) => void) {} + + /** + * Track one started stage until it exits. + * @param stage - the stage to supervise. + */ + add(stage: StageHandle): void { + this.live.add(stage) + void stage.exited.then((code) => { + this.live.delete(stage) + if (!this.stopping) this.onStale(stage.name, code) + }) + } + + /** + * Stop every live stage and resolve once all of them have exited. + * @param stop - initial signal and the grace period between escalation steps. + */ + async stop(stop: StageStop): Promise { + this.stopping = true + const escalation: (NodeJS.Signals | undefined)[] = [stop.signal] + if (stop.signal !== 'SIGTERM') escalation.push('SIGTERM') + for (const signal of escalation) { + if (this.live.size === 0) return + if (signal !== undefined) for (const stage of this.live) stage.kill(signal) + await this.settled(stop.graceMs) + } + for (const stage of this.live) stage.kill('SIGKILL') + await Promise.all([...this.live].map(stage => stage.exited)) + } + + private async settled(graceMs: number): Promise { + let timer: NodeJS.Timeout | undefined + await Promise.race([ + Promise.all([...this.live].map(stage => stage.exited)), + new Promise((resolve) => { timer = setTimeout(resolve, graceMs) }), + ]) + clearTimeout(timer) + } +} + +/** + * Grace period per escalation step. `dsh web` gives its plugin tree five seconds + * to dispose after the first signal; escalating earlier would turn the graceful + * drain into a forced exit. + */ +const STOP_GRACE_MS = 6_000 + +/** + * Spawn one long-lived stage with inherited stdio and hand it to the supervisor. + * @param supervisor - owner that reports an unexpected exit and stops the stage on shutdown. + * @param name - command label used in diagnostics. + * @param command - executable, resolved from the workspace bins when `local` is set. * @param args - command arguments. * @param local - whether to resolve `command` from the workspace's installed bins. */ -function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void { +function spawnStage( + supervisor: StageSupervisor, + name: string, + command: string, + args: readonly string[], + local: boolean, +): void { const child = execa(command, [...args], { cwd: repoRoot, stdio: 'inherit', preferLocal: local, reject: false, }) - stages.push({ kill: () => { child.kill() } }) - void child.then((result) => { - console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`) - process.exit(1) + supervisor.add({ + name, + exited: child.then(result => result.exitCode ?? null), + kill(signal) { child.kill(signal) }, }) } -/** The only capability this script needs from a live watcher process. */ -interface StageHandle { - readonly kill: () => void +/** + * Run the complete repository build with the launch environment untouched: + * `scripts/build.ts` samples the public client values itself. + * @returns the build's exit code, or null when a signal ended it. + */ +async function runBuild(): Promise { + const invocation = pnpmInvocation(['run', 'build']) + const result = await execa(invocation.command, invocation.args, { cwd: repoRoot, stdio: 'inherit', reject: false }) + return result.exitCode ?? null } const invokedPath = process.argv[1] const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href if (isMain) { + let options: DevWebArguments + try { + options = parseDevWebArguments(process.argv.slice(2)) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + console.error('dev-web: usage: pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]') + process.exit(1) + } + + // Shutdown is requested once, by a terminal signal or by a stage exiting on + // its own; `signal` stays unset for SIGINT because the terminal's process + // group already delivered it to every stage. + const shutdown = Promise.withResolvers() + const requested: { code?: number; signal?: NodeJS.Signals } = {} + const shutdownRequested = (): boolean => requested.code !== undefined + const requestShutdown = (code: number, signal: NodeJS.Signals | undefined): void => { + if (shutdownRequested()) return + requested.code = code + if (signal !== undefined) requested.signal = signal + shutdown.resolve(code) + } + const supervisor = new StageSupervisor((stage, code) => { + console.error(`dev-web: ${stage} exited (code ${String(code)}); stopping the other stages`) + requestShutdown(1, 'SIGTERM') + }) + // Persistent listeners, not `once`: the tsdown watchers bundle signal-exit, + // which re-raises a signal whenever it finds no other listener left and would + // terminate this process in the middle of the teardown below. + process.on('SIGINT', () => { requestShutdown(130, undefined) }) + process.on('SIGTERM', () => { requestShutdown(0, 'SIGTERM') }) + + const buildExit = options.skipBuild ? 0 : await runBuild() + if (shutdownRequested()) process.exit(await shutdown.promise) + if (buildExit !== 0) { + console.error(`dev-web: pnpm run build exited (code ${String(buildExit)})`) + process.exit(1) + } + const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env) for (const name of Object.keys(process.env)) { if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) { @@ -214,32 +390,14 @@ if (isMain) { process.exit(1) } - const args = process.argv.slice(2) - const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll=')) - if (args.some(a => a !== pollArg)) { - console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]') - process.exit(1) - } - const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500') - if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) { - console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`) - process.exit(1) - } - - // Registered before any stage starts: `stages` is read at signal time, so an - // interrupt during tsdown's initial builds still kills whatever is running. - const stop = (): void => { for (const stage of stages) stage.kill() } - process.once('SIGINT', stop) - process.once('SIGTERM', stop) - // tsc has no polling interval flag, so `--poll` selects its fixed-interval // watchers rather than an interval. Dropping that translation leaves tsc // natively watching on a network mount where inotify never fires: it stops // re-emitting lib/types, and the two later stages then rebuild forever from // stale input without printing anything. - spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [ + spawnStage(supervisor, `tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [ '-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput', - ...pollInterval !== undefined + ...options.pollInterval !== undefined ? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval'] : [], ], true) @@ -248,19 +406,34 @@ if (isMain) { // first build reads current lib bundles rather than whatever the last full // build left. Its own watch then covers later lib rewrites — those files are // in its module graph. - await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval) - // Through the shell's own `watch` script rather than vite's API: vite is not a - // repository-root dependency, and more importantly the vite root is its - // working directory — `resolve.dedupe` resolves react from that root, so - // running vite from anywhere but apps/web silently switches which react copy - // the bundle gets. - spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false) + const bundles = await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], options.pollInterval) + if (!shutdownRequested()) { + // Through the shell's own `watch` script rather than vite's API: vite is not a + // repository-root dependency, and more importantly the vite root is its + // working directory — `resolve.dedupe` resolves react from that root, so + // running vite from anywhere but apps/web silently switches which react copy + // the bundle gets. + spawnStage(supervisor, 'vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false) + // The same launch vector as the root `dsh` script, so the served Host runs + // from source exactly as `pnpm dsh web` would. + if (options.serve) { + spawnStage(supervisor, 'dsh web', process.execPath, [ + '--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', ...options.appArgs, + ], false) + } + console.log( + `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages` + + ` and ${String(libraryDirs.length)} statically linked library packages` + + (options.pollInterval !== undefined ? ` (polling ${String(options.pollInterval)}ms)` : '') + + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build` + + (options.serve ? ', serving through dsh web' : '') + + ':\n ' + + [...pluginDirs, ...libraryDirs].join('\n '), + ) + } - console.log( - `dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages` - + ` and ${String(libraryDirs.length)} statically linked library packages` - + (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '') - + `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n ` - + [...pluginDirs, ...libraryDirs].join('\n '), - ) + const exitCode = await shutdown.promise + await supervisor.stop({ signal: requested.signal, graceMs: STOP_GRACE_MS }) + for (const bundle of bundles) await bundle[Symbol.asyncDispose]() + process.exit(exitCode) } diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 13aa126d94..9996c05855 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1509,48 +1509,6 @@ "packages/llm/deepseek-llm-api-extensions/src/index.ts": { "1f519aabb00553384bc9c1c57064a1ed6fcd757032061e1b14e0718d91dd4817": 1 }, - "packages/llm/llm-deepseek/src/protocols/chat-completions/adapter.ts": { - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 1 - }, - "packages/llm/llm-deepseek/src/protocols/messages/adapter.ts": { - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 1 - }, - "packages/llm/llm-deepseek/src/protocols/messages/replay.ts": { - "9ebdedf03d9d83739a3d01d1c579242aceca09ea5be1be17f96eacf29e07cdea": 1 - }, - "packages/llm/llm-deepseek/tests/adapter.spec.ts": { - "34a631902f39d3a40e1baabacdccb986c3a2205cf2a6c8c1880061b87f39609d": 1, - "3f65e71f59e11feed30a6ca5bf870cad688d0bd161f6d52fe263a7aaf01bdb19": 2, - "b744a3b1ea1021e620ae97ba95bab540b731d7df9634e06fa3830ea7d9e5ebac": 1, - "cf2695bf7b16a8d315df73d326e926645d03499422d39f6b97bc569dda04137e": 1, - "d09a59b309ad22650abede4b87a64dc6afaf18c08e4313635a22f0121c0a6a92": 1, - "eea7d67f2cd0210a9a2d81bce690d485033ea7f120396b8a8b7f2016ea7ae396": 1 - }, - "packages/llm/llm-deepseek/tests/loader-composition.spec.ts": { - "59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1 - }, - "packages/llm/llm-deepseek/tests/messages/adapter.e2e.ts": { - "0dd149237441659d6dc629eb4e30914ac604236cbe023d90693e512b7e300a49": 1 - }, - "packages/llm/llm-deepseek/tests/messages/adapter.spec.ts": { - "ae6cc7d948e6e78f72fcd1764acabc731e7498c880b7d709710f8bfeee113ef8": 1, - "f1f72ca0a6b4cc00365db8c58c0477975b87b4293f0751815e1e971a7191c34d": 1 - }, - "packages/llm/llm-deepseek/tests/messages/files.spec.ts": { - "65121d4bf51b358c794748d5895b388149f6883d4ae88f6c16b39802813c223f": 1, - "eea7d67f2cd0210a9a2d81bce690d485033ea7f120396b8a8b7f2016ea7ae396": 1 - }, - "packages/llm/llm-deepseek/tests/messages/serialize.spec.ts": { - "02285954262e9d3346a33f080c4aeb89b7f6349014fd23d64e5aeb28a52f1ced": 1, - "8c7e2bcfcb534160c7df81de8490cfe4e9891e913c7c429a3f47f603d1129da3": 2, - "a394f4cff3c3d3755ffce930afc5cbee43f92de4ae2112a2325b64bdceb74ad2": 1, - "ab74fe25dbefba4c45ffa5bab2729b4bb372df0b946711e5d463686e4a0edc02": 2 - }, - "packages/llm/llm-deepseek/tests/serialize.spec.ts": { - "12712253c43410ab721190981c8b815d102f799ce6942bee3975b035c203dfa8": 1, - "98bef9260e144cee077af05112a07d3e9626caa43f06c66ad08f9c024f5469b1": 1, - "a394f4cff3c3d3755ffce930afc5cbee43f92de4ae2112a2325b64bdceb74ad2": 1 - }, "packages/llm/llm-pi-ai/src/catalog.ts": { "5b1064d0195802d24eaa5155c66eb381da6046a8ccd0cacc3d852336475da7ef": 1 }, @@ -2110,9 +2068,7 @@ "f77e5cbd2d2d911087f7faecc4b0b5bffdfb44ca5d0bcf7823ba9777feca7799": 1 }, "packages/subagent/subagent/src/catalog.ts": { - "29a67bf48723006b65c652af8a0ff30a4385bb14d651d91ca3059e1ddcb44fcf": 1, "3e704e331ed2fc5d4c0855e2455b31b82924c525f2e267416e393cdbd36c251b": 1, - "c3a7c8abe19131a8d98f4fb6a495b590e67a56cbf2e741caccfe730f8261edb0": 1, "fdc5fcade0ba1abbc426ccccb07a1ad4998253799b8561815047d95178441cdf": 1 }, "packages/subagent/subagent/src/internal.ts": { diff --git a/scripts/persistence-changes.spec.ts b/scripts/persistence-changes.spec.ts index 54b5a9588f..32ebc4d262 100644 --- a/scripts/persistence-changes.spec.ts +++ b/scripts/persistence-changes.spec.ts @@ -397,7 +397,104 @@ function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoo return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) } } +function versionedEvent(variants: readonly { version: number; mode: string; extra?: boolean | 'optional' }[]): PersistenceRoot { + const nodes: SchemaNode[] = [ + { kind: 'object', indices: [], properties: [ + { name: 'data', type: variants.length === 1 ? 3 : 1, optional: false }, + { name: 'type', type: 3 + variants.length * 3, optional: false }, + ] }, + { kind: 'union', types: variants.map((_, index) => 3 + index * 3) }, + { kind: 'primitive', type: 'string' }, + ] + for (const variant of variants) { + const index = nodes.length + nodes.push({ kind: 'object', indices: [], properties: [ + { name: 'version', type: index + 1, optional: false }, + { name: 'mode', type: index + 2, optional: false }, + ...(variant.extra ? [{ name: 'extra', type: 2, optional: variant.extra === 'optional' }] : []), + ] }, { kind: 'literal', value: variant.version }, { kind: 'literal', value: variant.mode }) + } + nodes.push({ kind: 'literal', value: 'example/value' }) + const schema = canonicalizeSchema(nodes, 0) + return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) } +} + describe('persistence change classification', () => { + it.each([1, 2])('acknowledges a higher payload version while preserving %s old variant(s) and the V4 checkpoint', (count) => { + const root = fixture() + const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }].slice(0, count) + const oldEvent = versionedEvent(variants) + const nextEvent = versionedEvent([...variants, { version: 1, mode: 'unknown', extra: true }]) + const before = { ...inventory({}, 4), roots: [...inventory({}, 4).roots.filter(root => root.kind !== 'event'), oldEvent] } + finalize(root, before) + const after = { ...before, roots: before.roots.map(root => root.kind === 'event' ? nextEvent : root) } + expect(classifyPersistenceChange(oldEvent, nextEvent)) + .toEqual([expect.objectContaining({ kind: 'payload-version-added', requiresVersionBump: false })]) + runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after) + verifyPersistenceChanges(root, after) + }) + + it.each([1, 2])('combines compatible old fields and a higher payload version with %s retained variant(s)', (count) => { + const root = fixture() + const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }].slice(0, count) + const oldEvent = versionedEvent(variants) + const nextEvent = versionedEvent([ + { ...variants[0]!, extra: 'optional' }, ...variants.slice(1), { version: 1, mode: 'unknown', extra: true }, + ]) + const before = { ...inventory({}, 4), roots: [...inventory({}, 4).roots.filter(root => root.kind !== 'event'), oldEvent] } + finalize(root, before) + const after = { ...before, roots: before.roots.map(root => root.kind === 'event' ? nextEvent : root) } + expect(classifyPersistenceChange(oldEvent, nextEvent)).toEqual([ + expect.objectContaining({ kind: 'optional-property-added', requiresVersionBump: false }), + expect.objectContaining({ kind: 'payload-version-added', requiresVersionBump: false }), + ]) + runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after) + verifyPersistenceChanges(root, after) + }) + + it('preserves the required-to-optional rule when a higher payload version is added', () => { + const before = versionedEvent([{ version: 0, mode: 'one-shot', extra: true }]) + const after = versionedEvent([ + { version: 0, mode: 'one-shot', extra: 'optional' }, { version: 1, mode: 'unknown' }, + ]) + expect(classifyPersistenceChange(before, after)).toEqual([ + expect.objectContaining({ kind: 'property-made-optional', requiresVersionBump: false }), + expect.objectContaining({ kind: 'payload-version-added', requiresVersionBump: false }), + ]) + }) + + it('rejects added and replaced same-version alternatives alongside a higher payload version', () => { + const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }] + const before = versionedEvent(variants) + for (const retained of [ + [...variants, { version: 0, mode: 'other' }], + [variants[0]!, { version: 0, mode: 'other' }], + ]) { + const after = versionedEvent([...retained, { version: 1, mode: 'unknown' }]) + expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true) + } + }) + + it('rejects changes to old payloads, missing versions, non-increasing versions, and removal of old readers', () => { + const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }] + const before = versionedEvent(variants) + for (const after of [ + versionedEvent([...variants, { version: 0, mode: 'unknown' }]), + versionedEvent([...variants, { version: -1, mode: 'unknown' }]), + versionedEvent([...variants, { version: 0.5, mode: 'unknown' }]), + versionedEvent([{ version: 0, mode: 'one-shot', extra: true }, variants[1]!, { version: 1, mode: 'unknown' }]), + versionedEvent([{ version: 1, mode: 'unknown' }]), + ]) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true) + const expanded = versionedEvent([...variants, { version: 1, mode: 'unknown' }]) + expect(classifyPersistenceChange(expanded, before).some(change => change.requiresVersionBump)).toBe(true) + expect(classifyPersistenceChange({ ...before, surface: true }, { ...expanded, surface: true }) + .some(change => change.requiresVersionBump)).toBe(true) + expect(classifyPersistenceChange({ ...before, kind: 'header' }, { ...expanded, kind: 'header' }) + .some(change => change.requiresVersionBump)).toBe(true) + const unversioned = unionBody([[{ name: 'mode', type: 2, optional: false }]]) + expect(classifyPersistenceChange(unversioned, expanded).some(change => change.requiresVersionBump)).toBe(true) + }) + it('treats a new optional payload subtree as one additive change even with required descendants', () => { const before = typeRoot('event:example/value', { value: 'string' }) const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } }) diff --git a/scripts/persistence-changes.ts b/scripts/persistence-changes.ts index 65f17b279f..fe0e7df5fd 100644 --- a/scripts/persistence-changes.ts +++ b/scripts/persistence-changes.ts @@ -66,6 +66,7 @@ const CHANGE_DESCRIPTIONS = { 'index-signature-changed': 'index signature changed', 'tuple-length-changed': 'tuple length changed', 'tuple-element-cardinality-changed': 'tuple element cardinality changed', + 'payload-version-added': 'event payload version added', 'union-variants-changed': 'union variants changed', 'source-policy-changed': 'source compatibility policy changed', 'attribution-kind-added': 'attribution-only source kind added', @@ -405,6 +406,30 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after: } const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode const newNode = newRoot.schema.nodes[newIndex] as SchemaNode + if (oldRoot.kind === 'event' && oldRoot.surface === false && scope === 'body' && path === `${key}.data`) { + const oldTypes = oldNode.kind === 'union' ? oldNode.types : [oldIndex] + const newTypes = newNode.kind === 'union' ? newNode.types : [newIndex] + const payloadVersion = (schema: CanonicalSchema, index: number): number | undefined => { + const node = schema.nodes[index] + if (node?.kind !== 'object') return undefined + const property = node.properties.find(property => property.name === 'version' && !property.optional) + const version = schema.nodes[property?.type ?? -1] + return version?.kind === 'literal' && typeof version.value === 'number' + && Number.isSafeInteger(version.value) && version.value >= 0 ? version.value : undefined + } + const versions = oldTypes.map(index => payloadVersion(oldRoot.schema, index)) + const nextVersions = newTypes.map(index => payloadVersion(newRoot.schema, index)) + if (versions.every(version => version !== undefined) && nextVersions.every(version => version !== undefined)) { + const maximum = Math.max(...versions) + const retained = newTypes.filter((_, index) => (nextVersions[index] as number) <= maximum) + if (retained.length === oldTypes.length && retained.length < newTypes.length) { + const retainedChanges = compareAlternatives(oldTypes, retained, path, scope, active) + if (retainedChanges.every(change => !change.requiresVersionBump)) { + return [...retainedChanges, describe(path, 'payload-version-added', false)] + } + } + } + } if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')] if (oldNode.kind === 'object' && newNode.kind === 'object') { const oldProps = new Map(oldNode.properties.map(property => [property.name, property])) diff --git a/scripts/session-snapshot-corpus-policy.spec.ts b/scripts/session-snapshot-corpus-policy.spec.ts index 952213cbbb..07fdd93bab 100644 --- a/scripts/session-snapshot-corpus-policy.spec.ts +++ b/scripts/session-snapshot-corpus-policy.spec.ts @@ -126,12 +126,14 @@ describe('recorded-session corpus policy', () => { ) it('bounds explicitly retained roles and requires a baseline/current majority', () => { + expect(assertSnapshotCorpusPolicy([ + baseline, current, + { ...completeV0, selectedVersions: Array(9).fill(0) }, ...adjacent, + ]).retainedRoles).toBe(11) expect(() => assertSnapshotCorpusPolicy([ - baseline, - current, - { ...completeV0, selectedVersions: Array(11).fill(0) }, - ...adjacent, - ])).toThrow(`Session corpus retains ${11 + adjacent.length} historical roles; maximum is 10`) + baseline, current, + { ...completeV0, selectedVersions: Array(10).fill(0) }, ...adjacent, + ])).toThrow('Session corpus retains 12 historical roles; maximum is 11') expect(() => assertSnapshotCorpusPolicy([ { ...baseline, selectedVersions: [3] }, completeV0, ...adjacent, ])).toThrow(`Session corpus requires a baseline/current majority; baseline=1, current=0, retained=${1 + adjacent.length}`) diff --git a/scripts/session-snapshot-corpus-policy.ts b/scripts/session-snapshot-corpus-policy.ts index 590f4f7f8e..85044e994f 100644 --- a/scripts/session-snapshot-corpus-policy.ts +++ b/scripts/session-snapshot-corpus-policy.ts @@ -22,7 +22,7 @@ export interface SnapshotCorpusGenerationSummary { } const RETAINED_BASELINE_VERSION = 3 -const MAX_RETAINED_ROLES = 10 +const MAX_RETAINED_ROLES = 11 const REQUIRED_V0_COVERAGE = new Set([ 'multi-hop', 'packed-row', diff --git a/scripts/session-snapshot-corpus.corpus.ts b/scripts/session-snapshot-corpus.corpus.ts index efa995e839..54d4124ad4 100644 --- a/scripts/session-snapshot-corpus.corpus.ts +++ b/scripts/session-snapshot-corpus.corpus.ts @@ -204,7 +204,7 @@ it('keeps V3 replay input plus bounded declared historical migration coverage', const summary = assertSnapshotCorpusPolicy(inventory) expect(summary.baselineRoles).toBeGreaterThan(0) expect(summary).toMatchObject({ - retainedRoles: 10, + retainedRoles: 11, retainedScenarios: 8, }) }) diff --git a/scripts/verify-application-entrypoints.spec.ts b/scripts/verify-application-entrypoints.spec.ts index 0c84043dfc..6ff6cb673a 100644 --- a/scripts/verify-application-entrypoints.spec.ts +++ b/scripts/verify-application-entrypoints.spec.ts @@ -99,8 +99,28 @@ describe('application entrypoints', () => { write(root, 'scripts/demo-ptc.mjs', "spawn('node', ['packages/example/app/src/bin.ts'])\n") expect(applicationEntrypointViolations(root)).toEqual([ - 'scripts/demo-ptc.mjs: application demo wrapper must launch apps/cli/src/bin.ts', - 'scripts/demo-ptc.mjs: application demo wrapper must not launch a package entry directly', + 'scripts/demo-ptc.mjs: application launcher wrapper must launch apps/cli/src/bin.ts', + 'scripts/demo-ptc.mjs: application launcher wrapper must not launch a package entry directly', + ]) + }) + + it('rejects a start:web script that bypasses the dsh launcher', () => { + const root = fixture() + write(root, 'package.json', JSON.stringify({ scripts: { 'start:web': 'node packages/example/app/src/bin.ts web' } })) + + expect(applicationEntrypointViolations(root)).toEqual([ + 'package.json scripts.start:web: application launcher script must launch apps/cli/src/bin.ts', + 'package.json scripts.start:web: application launcher script must not launch a package entry directly', + ]) + }) + + it('rejects a dev:web wrapper that serves without the dsh launcher', () => { + const root = fixture() + write(root, 'package.json', JSON.stringify({ scripts: { 'dev:web': 'tsx scripts/dev-web.ts --poll' } })) + write(root, 'scripts/dev-web.ts', "execa('vite', ['build', '--watch'])\n") + + expect(applicationEntrypointViolations(root)).toEqual([ + 'scripts/dev-web.ts: application launcher wrapper must launch apps/cli/src/bin.ts', ]) }) diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index 728dd0832f..b629e7e9ab 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -18,7 +18,7 @@ interface RootManifest { readonly scripts?: Record } -interface DemoPolicy { +interface LauncherPolicy { readonly kind: 'dsh-direct' | 'dsh-wrapper' readonly wrapper?: string } @@ -48,10 +48,16 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ ['python/sdk-runtime/runtime-bootstrap.mjs', 'private packaging-only runtime dispatcher'], ]) -/** Root demos are application wrappers and therefore must visibly select dsh. */ -const ROOT_DEMO_POLICIES = new Map([ +/** + * Root scripts that start an application must visibly select dsh: every `demo:*` + * script needs a row here, and the Web start and development scripts are pinned + * by name. + */ +const ROOT_LAUNCHER_POLICIES = new Map([ ['demo:ptc', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-ptc.mjs' }], ['demo:inspector', { kind: 'dsh-direct' }], + ['start:web', { kind: 'dsh-direct' }], + ['dev:web', { kind: 'dsh-wrapper', wrapper: 'scripts/dev-web.ts' }], ]) const SOURCE_PATTERNS = [ @@ -131,22 +137,23 @@ function referencesPackageEntry(source: string): boolean { return /packages\/[^/\s'"`]+\/[^/\s'"`]+\/(?:src|lib)\/[^\s'"`]+/.test(source) } -function rootDemoViolations(root: string): string[] { +function rootLauncherViolations(root: string): string[] { const manifestPath = resolve(root, 'package.json') if (!existsSync(manifestPath)) return [] const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as RootManifest const failures: string[] = [] for (const [name, commandValue] of Object.entries(manifest.scripts ?? {}).sort(([left], [right]) => left.localeCompare(right))) { - if (!name.startsWith('demo:')) continue - const command = typeof commandValue === 'string' ? commandValue : '' - const policy = ROOT_DEMO_POLICIES.get(name) + const policy = ROOT_LAUNCHER_POLICIES.get(name) if (policy === undefined) { - failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`) + if (name.startsWith('demo:')) { + failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`) + } continue } + const command = typeof commandValue === 'string' ? commandValue : '' if (policy.kind === 'dsh-direct') { - if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application demo must launch apps/cli/src/bin.ts`) - if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application demo must not launch a package entry directly`) + if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application launcher script must launch apps/cli/src/bin.ts`) + if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application launcher script must not launch a package entry directly`) continue } const wrapper = policy.wrapper @@ -156,12 +163,12 @@ function rootDemoViolations(root: string): string[] { } const wrapperPath = resolve(root, wrapper) if (!existsSync(wrapperPath)) { - failures.push(`${wrapper}: classified demo wrapper is missing`) + failures.push(`${wrapper}: classified launcher wrapper is missing`) continue } const source = readFileSync(wrapperPath, 'utf8') - if (!referencesDshCli(source)) failures.push(`${wrapper}: application demo wrapper must launch apps/cli/src/bin.ts`) - if (referencesPackageEntry(source)) failures.push(`${wrapper}: application demo wrapper must not launch a package entry directly`) + if (!referencesDshCli(source)) failures.push(`${wrapper}: application launcher wrapper must launch apps/cli/src/bin.ts`) + if (referencesPackageEntry(source)) failures.push(`${wrapper}: application launcher wrapper must not launch a package entry directly`) } return failures } @@ -175,7 +182,7 @@ export function applicationEntrypointViolations(root: string): string[] { return [ ...manifestBinViolations(root), ...executableSourceViolations(root), - ...rootDemoViolations(root), + ...rootLauncherViolations(root), ] } diff --git a/scripts/verify-package-meta.spec.ts b/scripts/verify-package-meta.spec.ts index fbdd05daf8..ac82e7e511 100644 --- a/scripts/verify-package-meta.spec.ts +++ b/scripts/verify-package-meta.spec.ts @@ -44,6 +44,57 @@ it('does not require locale resources for exported package name and description' expect(packageMetaProblems(root)).toEqual([]) }) +it.each([undefined, ['art'], ['art/*.svg'], ['./art/icon.svg']])('accepts a published package icon without locale resources: %j', (files) => { + manifest({ icon: './art/icon.svg', files }) + file('art/icon.svg', '') + expect(packageMetaProblems(root)).toEqual([]) +}) + +it.each([{ files: ['lib'] }, { files: ['art', '!art/icon.svg'] }])('rejects an icon omitted from package publication: %j', ({ files }) => { + manifest({ icon: './art/icon.svg', files }) + file('art/icon.svg', '') + expect(packageMetaProblems(root).join('\n')).toContain('files must include art/icon.svg') +}) + +it.each([null, false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates icon declarations without locale resources: %j', (icon) => { + manifest({ icon }) + file('../outside.svg', '') + expect(packageMetaProblems(root).join('\n')).toContain('Plugin metadata for @test/plugin:') +}) + +it('requires the root icon declaration to be exported', () => { + manifest({ icon: './icon.svg', exports: { '.': './entry.js' }, files: ['icon.svg'] }) + file('icon.svg', '') + expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its icon declaration through @test/plugin/package.json') +}) + +it.each([ + { './search/package.json': './resources/search/manifest.json' }, + { './*/package.json': './resources/*/manifest.json' }, + { './*': './resources/*' }, +])('validates icons of independent exported manifests: %j', (exports) => { + manifest({ exports, files: ['resources'] }) + const document = './*' in exports ? 'resources/search/package.json' : 'resources/search/manifest.json' + json(document, { icon: './icon.webp' }) + file('resources/search/icon.webp', 'webp') + expect(packageMetaProblems(root)).toEqual([]) + json(document, { icon: './missing.png' }) + expect(packageMetaProblems(root).join('\n')).toContain('missing.png') +}) + +it('requires publication of both an exported icon manifest and its image', () => { + manifest({ exports: { './search/package.json': './resources/search.json' }, files: ['resources/icon.svg'] }) + json('resources/search.json', { icon: './icon.svg' }) + file('resources/icon.svg', '') + expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/search.json') +}) + +it('ignores icon-like fields in unrelated source JSON', () => { + manifest() + json('data.json', { icon: false }) + expect(packageMetaProblems(root)).toEqual([]) +}) + it('excludes tests, installed dependencies, and build output from metadata discovery', () => { manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] }) json('tests/fixtures/locale/en.json', { meta: { title: false } }) @@ -91,6 +142,30 @@ it.each(['en', 'zh'])('rejects %s metadata resolved into build output without re expect(problems).not.toContain('Plugin metadata for') }) +it('does not read built locale JSON while checking a source icon declaration', () => { + manifest({ + icon: './icon.svg', files: ['icon.svg'], + exports: { './package.json': './package.json', './locale/en.json': './lib/locale/en.json' }, + }) + file('icon.svg', '') + file('lib/locale/en.json', '{') + json('locale/en.json', { meta: { title: 'Plugin' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('exports must expose locale/en.json') + expect(problems).not.toContain('Plugin metadata for') +}) + +it.each(['lib', 'tests'])('rejects an icon declaration when its only locale is excluded source: %s', (directory) => { + manifest({ + icon: './missing.svg', files: ['lib'], + exports: { './package.json': './package.json', './locale/en.json': `./${directory}/locale/en.json` }, + }) + file(`${directory}/locale/en.json`, '{}') + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('@test/plugin: icon metadata requires locale resources to resolve to source JSON') + expect(problems).not.toContain('Plugin metadata for') +}) + it('reads static metadata without evaluating the plugin entry', () => { manifest({ exports: { '.': './entry.js', './locale/*.json': './locale/*.json' } }) file('entry.js', "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'yes'); throw new Error('plugin entry executed')") diff --git a/scripts/verify-package-meta.ts b/scripts/verify-package-meta.ts index f5a486678f..de5720c6db 100644 --- a/scripts/verify-package-meta.ts +++ b/scripts/verify-package-meta.ts @@ -9,12 +9,14 @@ interface Manifest { name: string exports?: unknown files?: string[] + icon?: unknown } interface SourceJson { file: string metadata: boolean invalid: boolean + icon?: unknown } function sourceJsonFiles(dir: string): SourceJson[] { @@ -29,7 +31,7 @@ function sourceJsonFiles(dir: string): SourceJson[] { if (typeof contents !== 'object' || contents === null || Array.isArray(contents)) { return { file, metadata: false, invalid: true } } - return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false } + return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false, icon: 'icon' in contents ? contents.icon : undefined } }) } @@ -77,7 +79,10 @@ function packageProblems(manifestPath: string): string[] { const exported = typeof pkg.exports === 'object' && pkg.exports !== null ? Object.entries(pkg.exports) : [] for (const [key, target] of exported) { if (!key.startsWith('.')) continue - if (!key.includes('*')) candidates.add(key) + if (!key.includes('*')) { + candidates.add(key) + if (key.endsWith('/package.json')) candidates.add(key.slice(0, -'/package.json'.length)) + } const plugin = pluginOf(key) if (plugin !== undefined && !plugin.includes('*')) candidates.add(plugin) for (const pattern of targetsOf(target)) { @@ -85,6 +90,7 @@ function packageProblems(manifestPath: string): string[] { const value = substitution(pattern, `./${document.file}`) if (value === undefined) continue const request = key.replaceAll('*', value) + if (request.endsWith('/package.json')) candidates.add(request.slice(0, -'/package.json'.length)) const owner = pluginOf(request) if (owner === undefined) continue candidates.add(owner) @@ -116,6 +122,26 @@ function packageProblems(manifestPath: string): string[] { const file = lookup(resourceOf(filename)) return file === undefined ? [] : [{ filename, file }] }) + const iconManifest = lookup(`${specifier}/package.json`) + const iconDocument = iconManifest === undefined ? undefined : byPath.get(iconManifest) + let iconChecked = false + if (candidate === '.' && pkg.icon !== undefined && iconManifest !== join(dir, 'package.json')) { + problems.push(`${manifestPath}: exports must expose its icon declaration through ${pkg.name}/package.json`) + } + if (iconDocument?.icon !== undefined && resources.every(({ file }) => byPath.has(file))) { + iconChecked = true + const meta = readPluginMeta(specifier, parentURL) + if (meta?.error !== undefined) problems.push(meta.error) + if (meta?.icon !== undefined && typeof iconDocument.icon === 'string') { + const iconFile = relative(dir, resolve(dirname(join(dir, iconDocument.file)), iconDocument.icon)).replaceAll('\\', '/') + if (pkg.files !== undefined && !published(iconFile, pkg.files)) problems.push(`${manifestPath}: files must include ${iconFile}`) + if (iconDocument.file !== 'package.json' && pkg.files !== undefined && !published(iconDocument.file, pkg.files)) { + problems.push(`${manifestPath}: files must include ${iconDocument.file}`) + } + } + } else if (iconDocument?.icon !== undefined) { + problems.push(`${specifier}: icon metadata requires locale resources to resolve to source JSON`) + } if (!resources.some(({ file }) => byPath.get(file)?.metadata || byPath.get(file)?.invalid)) continue for (const { file } of resources) claimed.add(file) const english = lookup(resourceOf('en.json')) @@ -138,7 +164,7 @@ function packageProblems(manifestPath: string): string[] { problems.push(`${resourceOf(filename)}: ${file} must share the English locale directory ${directory}`) } } - if (sourceResources) { + if (sourceResources && !iconChecked) { const meta = readPluginMeta(specifier, parentURL) if (meta?.error !== undefined) problems.push(meta.error) } diff --git a/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml index 93ac7431eb..23c2d736c9 100644 --- a/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml +++ b/snapshots/session/deepseek-messages-input-history/cordis.snapshot.yml @@ -44,4 +44,4 @@ - id: deepseek-v4-flash - id: deepseek-v4-pro - id: messages-input-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/input-history.ts' diff --git a/snapshots/session/deepseek-messages-input-history/cordis.yml b/snapshots/session/deepseek-messages-input-history/cordis.yml index d143031868..019b755c26 100644 --- a/snapshots/session/deepseek-messages-input-history/cordis.yml +++ b/snapshots/session/deepseek-messages-input-history/cordis.yml @@ -1,3 +1,3 @@ - insert: - id: messages-input-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/input-history.ts' diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml index 92bac46628..0ae3966b64 100644 --- a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.snapshot.yml @@ -44,4 +44,4 @@ - id: deepseek-v4-flash - id: deepseek-v4-pro - id: messages-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/history.ts' diff --git a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml index 55b26d1e56..6a4ad74520 100644 --- a/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml +++ b/snapshots/session/deepseek-messages-invalid-tool-history/cordis.yml @@ -1,3 +1,3 @@ - insert: - id: messages-history-snapshot - name: '../../../packages/llm/llm-deepseek/tests/messages/fixtures/history.ts' + name: '../../../packages/llm/llm-deepseek/tests/fixtures/history.ts' diff --git a/snapshots/session/office-skills-no-renderer/cordis.yml b/snapshots/session/office-skills-no-renderer/cordis.yml index 4c963015fe..c4d3987921 100644 --- a/snapshots/session/office-skills-no-renderer/cordis.yml +++ b/snapshots/session/office-skills-no-renderer/cordis.yml @@ -6,7 +6,6 @@ - id: llm-deepseek config: - protocol: chat-completions thinking: enabled reasoningEffort: high maxTokens: 8192 diff --git a/snapshots/web/preset-migration/session.1.v2.jsonl b/snapshots/web/preset-migration/session.1.v2.jsonl new file mode 100644 index 0000000000..47ef5e21a9 --- /dev/null +++ b/snapshots/web/preset-migration/session.1.v2.jsonl @@ -0,0 +1,3 @@ +{"type":"session","version":2,"id":"{{session:2}}","createdAt":2,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":1,"agentPreset":"code","origin":"subagent","parentSession":"{{session:1}}"} +{"type":"subagent/descriptor","data":{"version":3,"provider":"spawn","mode":"one-shot","label":"historical child"}} +{"type":"feedback/record","data":{"text":"Child history remains readable independently."}} diff --git a/tsconfig.host.json b/tsconfig.host.json index e6a00d3086..ae3cfa0907 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -8,7 +8,8 @@ "rewriteRelativeImportExtensions": false }, "files": [ - "packages/client/ui-settings-general/src/client/desktop-update-bridge.ts" + "packages/client/ui-settings-general/src/client/desktop-update-bridge.ts", + "packages/client/ui-sidebar-browser/src/index.ts" ], "include": [ "apps/web/product-isolation.ts",