dsh-vision-fallback 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 - 2026-09-03
4
+
5
+ - Adapted Host listeners to the current Cordis effect lifecycle.
6
+ - Stopped mutating frozen `llm/stream` requests and rerouted compaction with a copied request.
7
+ - Added explicit DSH Store compatibility metadata for `0.1.2-alpha.3`, `0.1.2-alpha.4`, and `0.1.2-alpha.5`.
8
+ - Declared the supported Node.js and DSH ranges and expanded bundle manifest coverage.
9
+
3
10
  ## 0.9.1 - 2026-08-18
4
11
 
5
12
  - Added the `dsh-plugin` npm keyword and a regression test for the installable DSH bundle manifest.
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  Silent vision enhancement for [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness): keep your real text-only main model (e.g. `deepseek-v4-flash`), and let chat images "just work" — every image you drop, paste, or reference in the chat box is automatically sent to a fixed vision model, converted into a factual text observation, and handed to your main model as hidden context. The UI keeps showing your original image; no model groups, no model switching, no extra tools.
6
6
 
7
+ Compatible with the current DSH Store window: `0.1.2-alpha.3`, `0.1.2-alpha.4`, and `0.1.2-alpha.5`. Durable MCP / ACP image attachments and nested images forwarded by PTC Mode use the same vision bridge. When enabled, the plugin intentionally keeps advertising image input; otherwise the chat composer rejects the image before it can reach the bridge.
8
+
7
9
  ## Why
8
10
 
9
11
  - DeepSeek V4 Flash / Pro and other strong coding models are **text-only**: dropping an image into the chat box fails with "model does not support image input".
@@ -31,13 +33,28 @@ agent/pre-step ──► image + current question + recent context
31
33
  4. A **model-only surface replacement** swaps the image for the vision observation before the request reaches the main model.
32
34
  5. Switching the main model (DeepSeek, Kimi, MiniMax, ...) never changes the fixed vision model.
33
35
 
34
- ### Complete call paths
36
+ ### MCP / ACP / PTC images
37
+
38
+ DSH `0.1.0-rc.7` persists images produced by MCP, ACP, and PTC as durable attachments, then exposes them as core `image` content blocks. This plugin recursively handles images in ordinary messages, `tool-result`, and nested `tool-result` content while preserving the original text/image order:
39
+
40
+ - MCP / ACP durable attachments are read by `attachmentId`, not temporary file paths;
41
+ - nested PTC subtool images are converted into vision observations;
42
+ - multiple images are sent to the vision model in their original order;
43
+ - only the model-visible surface is replaced, so the original UI image remains intact.
44
+
45
+ The image-capability override is an intentional entry-point compatibility layer. It does not claim that the main model has native visual reasoning; it lets the image reach DSH so the bridge can convert it into text context.
35
46
 
36
- - **Normal conversations**: `agent/pre-step` processes images before the main-model request and projects observations into the model view.
37
- - **Context compaction**: the `llm/stream` path with `purpose: "compaction"` reuses existing observations first and only describes genuinely unseen images.
38
- - **Tool-result images**: images inside `tool/result` events are projected for the model while the original tool result remains visible and traceable in the UI.
39
- - **Reloads and restarts**: observations are persisted to `observations.json`, so the same session/message position does not trigger another vision call after restart.
40
- - **Model capability detection**: in `auto` mode the plugin reads the main model's real `inputModalities`; it stays out of the way for image-capable models and bridges only text-only routes.
47
+ ### DSH compatibility evidence
48
+
49
+ Verified on 2026-09-03 with Node.js `24.16.0` and a separate disposable `DSH_HOME` for each release:
50
+
51
+ | DSH release | Local-path install | `--dump-config` | Authenticated cold start | Uninstall |
52
+ | --- | --- | --- | --- | --- |
53
+ | `0.1.2-alpha.3` | passed | passed | HTTP 200 | passed |
54
+ | `0.1.2-alpha.4` | passed | passed | HTTP 200 | passed |
55
+ | `0.1.2-alpha.5` | passed | passed | HTTP 200 | passed |
56
+
57
+ The Profile operations use the official CLI with `plugin --profile web add -w <local-path>` and `remove -w dsh-vision-fallback`. The runtime fix does not mutate the deep-frozen `llm/stream` request introduced by current DSH builds; compaction creates a copied request and performs one guarded nested dispatch.
41
58
 
42
59
  ## Install
43
60
 
@@ -54,8 +71,8 @@ dsh plugin --profile web add dsh-vision-fallback
54
71
  ```sh
55
72
  git clone https://github.com/1HelloMan1/dsh-vision-fallback.git
56
73
  cd dsh-vision-fallback
57
- pnpm install --config.minimumReleaseAge=0 # rc.6 peers need the release-age flag bypassed
58
- pnpm test # 22 unit tests
74
+ pnpm install --config.minimumReleaseAge=0 # pre-release peers may need the release-age flag bypassed
75
+ pnpm test # node --test test/*.test.mjs
59
76
  dsh plugin --profile web add "$PWD"
60
77
  ```
61
78
 
@@ -82,7 +99,7 @@ Open **Settings → 视觉增强 / Vision Enhancement** in the DSH web UI. It ex
82
99
  | Vision model | `mimo-v2.5` | OpenAI-compatible `model` |
83
100
  | Base URL | `https://opencode.ai/zen/go/v1` | The plugin appends `/chat/completions` |
84
101
  | Credential ref | `OPENCODE_GO_API_KEY` | Key resolved from the DSH credential store (never written to env files) |
85
- | Max tokens / Timeout / Max bytes | `1536` / `60000` / `15MB` | Vision request limits |
102
+ | Max tokens / Timeout / Max bytes | `1536` / `60000` / `15MB` | Vision request limits; oversized images fail |
86
103
  | Recent context | `includeRecentContext: true`, `contextMessages: 6`, `contextMaxChars: 6000` | How much recent chat to attach for the vision model |
87
104
  | Prompt | (Chinese detailed-analysis prompt) | Analysis instruction; the user's question is appended automatically |
88
105
  | Tag result | `true` | Prepend `【视觉观察:<model>】` to the observation |
@@ -145,12 +162,9 @@ Observations are keyed by **the image's occurrence in a session** (session id +
145
162
 
146
163
  The cache is capped at 256 entries (LRU eviction); failed results are never cached.
147
164
 
148
- Cache location: when `usageLogPath` is set, `observations.json` is written beside that log; otherwise it is stored at
149
- `<dshHome>/vision-fallback/observations.json`. This lets compaction, event replay, and service restarts reuse successful observations.
150
-
151
- ### Compaction, tool results, and restarts
165
+ ### About image compression
152
166
 
153
- When the main model can already see images, the bridge does not call the vision model. If a later text-only compaction model encounters the original image event, the plugin first reuses the main model's projected observation and the persistent cache, then handles only images that truly have no observation. This prevents every compaction from re-describing the entire image history while still allowing the same image at a new message position to be re-read with fresh context.
167
+ In the current version, “compression” means **reusing an existing vision observation during conversation-context compaction**; it does not re-run vision analysis. The plugin does not yet resize, transcode, or quality-compress image files. Images above `maxBytes` fail explicitly instead of being silently changed. A future real image-compression feature should report the before/after byte counts and media type.
154
168
 
155
169
  ## Default vision route
156
170
 
@@ -165,7 +179,7 @@ The OpenCode community `opencode-see-image` hands a `filePath` + task `question`
165
179
  ## Development
166
180
 
167
181
  ```sh
168
- pnpm test # node --test test/*.test.mjs — 22 tests
182
+ pnpm test # node --test test/*.test.mjs
169
183
  ```
170
184
 
171
185
  Structure:
package/README.zh.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  为 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 提供**全局静默视觉增强**:你继续在会话顶部直接选择真实主模型(如 `deepseek-v4-flash`),聊天框里拖入、粘贴、引用的图片会自动交给固定视觉模型,转成事实性文字观察后作为隐藏上下文交给主模型。界面始终显示你的原始图片——不新增"视觉回退"模型分组、不切换模型、不调用额外工具。
6
6
 
7
+ 兼容当前 DSH STORE 版本窗口:`0.1.2-alpha.3`、`0.1.2-alpha.4`、`0.1.2-alpha.5`。MCP / ACP 的持久图片附件,以及 PTC Mode 转发的嵌套图片,都会沿用同一套视觉桥处理。插件启用时会保留图片能力声明;否则聊天框会在图片到达视觉桥之前直接提示“模型不支持图片输入”。
8
+
7
9
  ## 为什么需要
8
10
 
9
11
  - DeepSeek V4 Flash/Pro 等强编码模型是**纯文本**的:聊天框发图会直接报"模型不支持图片输入"。
@@ -31,13 +33,28 @@ agent/pre-step ──► 图片 + 当前问题 + 最近上下文
31
33
  4. **仅模型可见的 surface replacement** 把图片换成视觉观察后再交给主模型。
32
34
  5. 切换主模型(DeepSeek、Kimi、MiniMax……)不会改变固定视觉模型。
33
35
 
34
- ### 完整调用线路
36
+ ### MCP / ACP / PTC 图片
37
+
38
+ DSH `0.1.0-rc.7` 会把 MCP、ACP 和 PTC 产生的图片保存为持久附件,再以核心 `image` 内容块交给后续链路。本插件会递归处理普通消息、`tool-result` 和多层嵌套 `tool-result` 中的图片,并保持图片与文字的原始顺序:
39
+
40
+ - MCP / ACP 持久附件:按 `attachmentId` 读取,不依赖临时文件路径;
41
+ - PTC 嵌套图片:子调用返回的图片也会转换为视觉观察;
42
+ - 多张图片:逐张调用视觉模型,观察结果对应原始出现顺序;
43
+ - 原始界面:只替换模型视图,聊天界面仍保留原图。
44
+
45
+ 插件的图片能力声明是必要的入口兼容层。它不是说主模型真的具备视觉推理能力,而是允许图片先进入 DSH,再由视觉桥转成主模型可用的文字上下文。
35
46
 
36
- - **普通对话**:`agent/pre-step` 在主模型请求前处理图片,并把视觉观察投影到模型视图。
37
- - **上下文压缩**:`llm/stream` 的 `purpose: "compaction"` 线路会先复用已有观察;只有确实没有观察的图片才补一次识别。
38
- - **工具结果图片**:`tool/result` 中的图片也会进入模型投影,界面仍保留原始图片,不把工具结果改成用户不可追溯的纯文本。
39
- - **会话重载 / 重启**:观察结果持久化到 `observations.json`,重启后同一会话、同一消息位置不会再次调用视觉模型。
40
- - **模型能力判断**:`auto` 模式会读取主模型的真实 `inputModalities`;主模型原生支持图片时不重复接管,纯文本主模型才走视觉桥。
47
+ ### DSH 兼容性证据
48
+
49
+ 2026-09-03 使用 Node.js `24.16.0` 验证,每个版本均使用独立的一次性 `DSH_HOME`:
50
+
51
+ | DSH 版本 | 本地路径安装 | `--dump-config` | 带认证冷启动 | 卸载 |
52
+ | --- | --- | --- | --- | --- |
53
+ | `0.1.2-alpha.3` | 通过 | 通过 | HTTP 200 | 通过 |
54
+ | `0.1.2-alpha.4` | 通过 | 通过 | HTTP 200 | 通过 |
55
+ | `0.1.2-alpha.5` | 通过 | 通过 | HTTP 200 | 通过 |
56
+
57
+ Profile 操作使用官方 CLI:`plugin --profile web add -w <local-path>` 和 `remove -w dsh-vision-fallback`。新版 DSH 会深冻结 `llm/stream` 请求,插件不再原地修改它;上下文压缩会复制请求并执行一次带重入保护的嵌套分发。
41
58
 
42
59
  ## 安装
43
60
 
@@ -54,8 +71,8 @@ dsh plugin --profile web add dsh-vision-fallback
54
71
  ```sh
55
72
  git clone https://github.com/1HelloMan1/dsh-vision-fallback.git
56
73
  cd dsh-vision-fallback
57
- pnpm install --config.minimumReleaseAge=0 # rc.6 peer 依赖需绕过发布年龄策略
58
- pnpm test # 22 个单元测试
74
+ pnpm install --config.minimumReleaseAge=0 # 预发布 peer 依赖可能需绕过发布年龄策略
75
+ pnpm test # node --test test/*.test.mjs
59
76
  dsh plugin --profile web add "$PWD"
60
77
  ```
61
78
 
@@ -82,7 +99,7 @@ dsh --profile web --dump-config # 应出现 "# == dsh-vision-fallback" 层
82
99
  | 视觉模型 | `mimo-v2.5` | OpenAI 兼容 `model` |
83
100
  | API 地址 | `https://opencode.ai/zen/go/v1` | 插件自动拼 `/chat/completions` |
84
101
  | 凭据引用 | `OPENCODE_GO_API_KEY` | 从 DSH 凭证系统解析(不写入环境变量) |
85
- | 输出上限 / 超时 / 图片上限 | `1536` / `60000` / `15MB` | 视觉请求限制 |
102
+ | 输出上限 / 超时 / 图片上限 | `1536` / `60000` / `15MB` | 视觉请求限制;超过图片上限会失败 |
86
103
  | 最近上下文 | `includeRecentContext: true`、`contextMessages: 6`、`contextMaxChars: 6000` | 附带多少最近对话给视觉模型 |
87
104
  | 提示词 | (中文详细分析) | 分析指令;用户问题自动追加 |
88
105
  | 结果标注 | `true` | 观察前加 `【视觉观察:<model>】` |
@@ -145,12 +162,9 @@ API key 通过 DSH **凭证系统**(`~/.dsh/.credentials.yaml`)解析,`pro
145
162
 
146
163
  缓存上限 256 条(LRU 淘汰),失败结果不入缓存。
147
164
 
148
- 缓存文件位置:如果设置了 `usageLogPath`,缓存写在同一目录的 `observations.json`;否则使用
149
- `<dshHome>/vision-fallback/observations.json`。因此压缩、事件回放和服务重启都可以复用已经成功得到的观察。
150
-
151
- ### 压缩、工具结果与重启
165
+ ### 关于图片压缩
152
166
 
153
- 主模型能看图时,视觉桥不会重复调用视觉模型;但如果后续使用纯文本压缩模型,压缩线路可能再次遇到原始图片。插件会优先复用主模型已有的视觉投影和持久化观察,再处理真正没有结果的图片。这样可以避免“每次压缩重新识别整段历史图片”,同时保留新消息位置重新识别的能力。
167
+ 当前版本的“压缩”指**上下文压缩时复用已经得到的视觉观察**,不会重新识图;插件暂时不会对图片文件做缩放、转码或质量压缩。图片超过 `maxBytes` 时会直接报告失败,不会静默改变原图。后续如增加真正的图片压缩,会单独记录压缩前后字节数和媒体类型。
154
168
 
155
169
  ## 默认视觉路由
156
170
 
@@ -165,7 +179,7 @@ OpenCode 社区的 `opencode-see-image` 把 `filePath` 和针对当前任务的
165
179
  ## 开发
166
180
 
167
181
  ```sh
168
- pnpm test # node --test test/*.test.mjs — 22 个测试
182
+ pnpm test # node --test test/*.test.mjs
169
183
  ```
170
184
 
171
185
  目录结构:
package/lib/index.js CHANGED
@@ -12,12 +12,11 @@ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
12
12
  import { dirname, join } from "node:path";
13
13
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
14
14
  import { attributionHeaders } from "@deepseek-ai/dsh-llm";
15
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
16
15
  import z from "@deepseek-ai/schemastery";
17
16
 
18
17
  const name = "vision-fallback";
19
18
  const inject = ["llm"];
20
- const NS = settingsNamespace("vision-fallback");
19
+ const NS = "vision-fallback";
21
20
  const CONFIG_ROUTE = "/plugins/dsh-vision-fallback/config";
22
21
  const MAX_CONFIG_BODY_BYTES = 64 * 1024;
23
22
 
@@ -128,6 +127,32 @@ function installConfigRoute(ctx, current, settingsSource) {
128
127
  });
129
128
  }
130
129
 
130
+ function installSettingsSectionCompat(ctx, ns, schema, entry, hooks) {
131
+ ctx.inject(["settings"], (sctx) => {
132
+ if (typeof sctx.settings.installSection === "function") {
133
+ sctx.settings.installSection(ctx, ns, schema, entry, hooks);
134
+ return;
135
+ }
136
+
137
+ const scope = sctx.settings.register(ns, schema, {
138
+ base: entry,
139
+ ...(hooks.validate === undefined ? {} : { validate: hooks.validate })
140
+ });
141
+ const ownerIsUnloading = () => ctx.fiber !== undefined && (ctx.fiber.state === 4 || ctx.fiber.state === 5);
142
+ hooks.setSource(() => scope.get());
143
+ hooks.onChange();
144
+ const disposeWatch = scope.watch(() => {
145
+ if (!ownerIsUnloading()) hooks.onChange();
146
+ });
147
+ sctx.effect(() => disposeWatch, `vision-fallback: 停止监听 ${ns} 设置`);
148
+ sctx.effect(() => () => {
149
+ if (ownerIsUnloading()) return;
150
+ hooks.setSource(() => entry);
151
+ hooks.onChange();
152
+ }, `vision-fallback: 释放 ${ns} 设置源`);
153
+ });
154
+ }
155
+
131
156
  function contentHasImage(content) {
132
157
  return content.some(
133
158
  (block) => block?.type === "image" ||
@@ -757,13 +782,17 @@ function apply(ctx, config) {
757
782
  const controller = new VisionFallbackController(ctx, () => current());
758
783
  const replacements = new ReplacementCoordinator(ctx.logger);
759
784
  const restoreCapability = installCapabilityOverride(ctx, () => current());
785
+ const reroutedCompactionRequests = new WeakSet();
760
786
 
761
787
  ctx.effect(() => () => {
762
788
  replacements.dispose();
763
789
  restoreCapability();
764
790
  }, "vision-fallback: 恢复模型图片能力检查与消息投影");
765
- ctx.on("session/event", (session, event) => replacements.onSessionEvent(session, event));
766
- ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
791
+ ctx.effect(
792
+ () => ctx.on("session/event", (session, event) => replacements.onSessionEvent(session, event)),
793
+ "vision-fallback: 监听会话图片事件"
794
+ );
795
+ ctx.effect(() => ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
767
796
  const decision = await next();
768
797
  if (decision.kind === "reject" || signal.aborted) return decision;
769
798
  const cfg = current();
@@ -780,10 +809,10 @@ function apply(ctx, config) {
780
809
  replacements.stage(agent.session, modelMessages, rewritten);
781
810
  replacements.flush(agent.session);
782
811
  return decision;
783
- });
812
+ }), "vision-fallback: 发送前生成视觉观察");
784
813
 
785
- ctx.on("llm/stream", (options, next) => (async function* () {
786
- if (options.purpose !== "compaction") {
814
+ ctx.effect(() => ctx.on("llm/stream", (options, next) => (async function* () {
815
+ if (reroutedCompactionRequests.has(options) || options.purpose !== "compaction") {
787
816
  yield* next();
788
817
  return;
789
818
  }
@@ -805,11 +834,24 @@ function apply(ctx, config) {
805
834
  resolveSession(ctx, options.sessionId),
806
835
  replacements
807
836
  );
808
- options.messages = projected.some((message) => contentHasImage(message.content ?? []))
837
+ const messages = projected.some((message) => contentHasImage(message.content ?? []))
809
838
  ? await replaceLeftoverCompactionImages(projected, controller, options.sessionId)
810
839
  : projected;
811
- yield* next();
812
- })(), { global: true, prepend: true });
840
+ if (messages === options.messages) {
841
+ yield* next();
842
+ return;
843
+ }
844
+
845
+ // DSH 0.1.2-alpha.5 起 LOOP 请求为深冻结只读对象。通过一次受控的
846
+ // 嵌套 stream 传入新请求,避免修改原对象;WeakSet 防止再次进入本桥。
847
+ const rerouted = { ...options, messages };
848
+ reroutedCompactionRequests.add(rerouted);
849
+ try {
850
+ yield* ctx.llm.stream(rerouted);
851
+ } finally {
852
+ reroutedCompactionRequests.delete(rerouted);
853
+ }
854
+ })(), { global: true, prepend: true }), "vision-fallback: 压缩时复用视觉观察");
813
855
 
814
856
  ctx.inject(["settings"], (sctx) => {
815
857
  settingsService = sctx.settings;
@@ -817,7 +859,7 @@ function apply(ctx, config) {
817
859
  if (settingsService === sctx.settings) settingsService = undefined;
818
860
  }, "vision-fallback: 释放 Settings 服务引用");
819
861
  });
820
- installSettingsSection(ctx, NS, Config, config, {
862
+ installSettingsSectionCompat(ctx, NS, Config, config, {
821
863
  validate: assertVisionConfig,
822
864
  setSource: (source) => {
823
865
  current = source;
@@ -838,6 +880,7 @@ export {
838
880
  inject,
839
881
  installCapabilityOverride,
840
882
  installConfigRoute,
883
+ installSettingsSectionCompat,
841
884
  name,
842
885
  rewriteMessages,
843
886
  withImageCapability
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-fallback",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "DSH 静默视觉增强:主模型照常选择,图片自动交给固定视觉模型后以隐藏上下文返回主模型。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -46,6 +46,14 @@
46
46
  "bundle": {
47
47
  "patch": "./cordis.patch.yml"
48
48
  },
49
+ "compatibility": {
50
+ "dsh": ">=0.1.1-rc.2 <0.2.0",
51
+ "dshReleases": {
52
+ "0.1.2-alpha.3": "compatible",
53
+ "0.1.2-alpha.4": "compatible",
54
+ "0.1.2-alpha.5": "compatible"
55
+ }
56
+ },
49
57
  "client": {
50
58
  "inject": [
51
59
  "@deepseek-ai/dsh-client-connection",
@@ -55,13 +63,16 @@
55
63
  "platform": "web"
56
64
  }
57
65
  },
66
+ "engines": {
67
+ "node": ">=22.13.0"
68
+ },
58
69
  "peerDependencies": {
59
70
  "@deepseek-ai/cordis": "^4.0.1",
60
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
61
- "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
62
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
63
- "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
71
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0",
72
+ "@deepseek-ai/dsh-attachment": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0",
73
+ "@deepseek-ai/dsh-settings": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0",
74
+ "@deepseek-ai/dsh-credentials": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0",
64
75
  "@deepseek-ai/schemastery": "^3.18.1",
65
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6"
76
+ "@deepseek-ai/dsh-home-paths": ">=0.1.1-rc.2 <0.1.2 || >=0.1.2-alpha.3 <0.2.0"
66
77
  }
67
78
  }
@@ -4,12 +4,14 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
6
  import {
7
+ Config,
7
8
  CONFIG_ROUTE,
8
9
  ReplacementCoordinator,
9
10
  VisionFallbackController,
10
11
  apply,
11
12
  buildVisionPrompt,
12
13
  installCapabilityOverride,
14
+ installSettingsSectionCompat,
13
15
  rewriteMessages,
14
16
  withImageCapability
15
17
  } from "../lib/index.js";
@@ -252,6 +254,64 @@ test("历史工具结果中的图片只以文字形式进入模型视图", async
252
254
  }
253
255
  });
254
256
 
257
+ test("rc.7 持久附件引用与 PTC 多层嵌套图片按顺序转换", async () => {
258
+ const originalFetch = globalThis.fetch;
259
+ const visionRequests = [];
260
+ globalThis.fetch = async (_url, options) => {
261
+ visionRequests.push(JSON.parse(options.body));
262
+ return {
263
+ ok: true,
264
+ json: async () => ({ choices: [{ message: { content: `视觉观察 ${visionRequests.length}` } }] })
265
+ };
266
+ };
267
+ const firstAttachment = {
268
+ attachmentId: "sha256:rc7-mcp-image",
269
+ mediaType: "image/png",
270
+ name: "mcp-result.png"
271
+ };
272
+ const nestedAttachment = {
273
+ attachmentId: "sha256:rc7-ptc-image",
274
+ mediaType: "image/jpeg",
275
+ name: "ptc-nested.jpg"
276
+ };
277
+ const original = userMessage("rc7-rich-result", [
278
+ { type: "text", text: "工具返回了两张图:" },
279
+ { type: "image", attachment: firstAttachment },
280
+ {
281
+ type: "tool-result",
282
+ toolCallId: "mcp-read-image",
283
+ content: [
284
+ { type: "text", text: "PTC 子调用结果" },
285
+ {
286
+ type: "tool-result",
287
+ toolCallId: "ptc-read-image",
288
+ content: [{ type: "image", attachment: nestedAttachment }],
289
+ isError: false
290
+ }
291
+ ],
292
+ isError: false
293
+ },
294
+ { type: "text", text: "请按出现顺序说明。" }
295
+ ]);
296
+ const controller = new VisionFallbackController(createServiceContext(), () => config);
297
+
298
+ try {
299
+ const [rewritten] = await controller.preprocess([original], [original], undefined, "rc7-session");
300
+ assert.equal(visionRequests.length, 2);
301
+ assert.equal(visionRequests[0].messages[1].content[1].image_url.url.startsWith("data:image/png;base64,"), true);
302
+ assert.equal(visionRequests[1].messages[1].content[1].image_url.url.startsWith("data:image/jpeg;base64,"), true);
303
+ assert.equal(rewritten.content[0].text, "工具返回了两张图:");
304
+ assert.match(rewritten.content[1].text, /视觉观察 1/);
305
+ assert.equal(rewritten.content[2].content[0].text, "PTC 子调用结果");
306
+ assert.match(rewritten.content[2].content[1].content[0].text, /视觉观察 2/);
307
+ assert.equal(JSON.stringify(rewritten).includes('"type":"image"'), false);
308
+ assert.equal(original.content[1].type, "image");
309
+ assert.equal(original.content[2].content[1].content[0].type, "image");
310
+ } finally {
311
+ globalThis.fetch = originalFetch;
312
+ }
313
+ });
314
+
255
315
  test("图片能力覆盖可随开关变化并能恢复", async () => {
256
316
  let current = config;
257
317
  const original = async (provider, model) => ({
@@ -339,13 +399,41 @@ test("插件注册静默预处理钩子而不注册新模型 adapter", () => {
339
399
  assert.equal(typeof cleanup, "function");
340
400
  assert.equal(registeredSettings.namespace, "vision-fallback");
341
401
  assert.equal(registeredSettings.options.base, config);
342
- assert.equal(settingsEffects.length, 2);
402
+ assert.equal(settingsEffects.length, 3);
343
403
  assert.equal(webEffects.length, 1);
344
404
  assert.equal(registeredRoute.kind, "exact");
345
405
  assert.equal(registeredRoute.path, CONFIG_ROUTE);
346
406
  assert.equal(replacements.length, 0);
347
407
  });
348
408
 
409
+ test("alpha.5 使用 SettingsProvider.installSection 注册设置", () => {
410
+ let installed;
411
+ const ctx = {
412
+ inject(dependencies, callback) {
413
+ assert.deepEqual(dependencies, ["settings"]);
414
+ callback({
415
+ settings: {
416
+ installSection(...args) {
417
+ installed = args;
418
+ }
419
+ }
420
+ });
421
+ }
422
+ };
423
+ const hooks = {
424
+ validate: assert.doesNotThrow,
425
+ setSource() {},
426
+ onChange() {}
427
+ };
428
+
429
+ installSettingsSectionCompat(ctx, "vision-fallback", Config, config, hooks);
430
+ assert.equal(installed[0], ctx);
431
+ assert.equal(installed[1], "vision-fallback");
432
+ assert.equal(installed[2], Config);
433
+ assert.equal(installed[3], config);
434
+ assert.equal(installed[4], hooks);
435
+ });
436
+
349
437
  test("插件私有配置路由允许本机读取和保存", async () => {
350
438
  let route;
351
439
  let stored = config;
@@ -518,7 +606,7 @@ test("auto 模式按主模型真实视觉能力决定是否接管", async () =>
518
606
  resolveModelInfo: async () => ({ provider: "opencode-go", id: "x", name: "x", inputModalities: realModalities })
519
607
  },
520
608
  logger: { error() {} },
521
- effect() {},
609
+ effect(factory) { factory(); },
522
610
  on(name, handler) {
523
611
  if (name === "agent/pre-step") preStep = handler;
524
612
  },
@@ -528,10 +616,10 @@ test("auto 模式按主模型真实视觉能力决定是否接管", async () =>
528
616
  settings: {
529
617
  register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} })
530
618
  },
531
- effect() {}
619
+ effect(factory) { factory(); }
532
620
  });
533
621
  } else {
534
- cb({ webServer: { register: () => () => {} }, effect() {} });
622
+ cb({ webServer: { register: () => () => {} }, effect(factory) { factory(); } });
535
623
  }
536
624
  },
537
625
  get(name) {
@@ -706,12 +794,17 @@ test("compaction 请求不再重新识图,普通请求保持原样", async ()
706
794
 
707
795
  const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
708
796
  let streamHandler;
797
+ const rerouted = [];
709
798
  const ctx = {
710
799
  llm: {
711
- resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
800
+ resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] }),
801
+ stream(options) {
802
+ rerouted.push(options);
803
+ return emptyStream();
804
+ }
712
805
  },
713
806
  logger: { error() {} },
714
- effect() {},
807
+ effect(factory) { factory(); },
715
808
  on(name, handler) {
716
809
  if (name === "llm/stream") streamHandler = handler;
717
810
  },
@@ -721,10 +814,10 @@ test("compaction 请求不再重新识图,普通请求保持原样", async ()
721
814
  settings: {
722
815
  register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} })
723
816
  },
724
- effect() {}
817
+ effect(factory) { factory(); }
725
818
  });
726
819
  } else {
727
- cb({ webServer: { register: () => () => {} }, effect() {} });
820
+ cb({ webServer: { register: () => () => {} }, effect(factory) { factory(); } });
728
821
  }
729
822
  },
730
823
  get(name) {
@@ -742,19 +835,21 @@ test("compaction 请求不再重新识图,普通请求保持原样", async ()
742
835
  apply(ctx, { ...config, mode: "auto" });
743
836
  assert.equal(typeof streamHandler, "function");
744
837
 
745
- const compactionOptions = {
838
+ const compactionOptions = Object.freeze({
746
839
  purpose: "compaction",
747
840
  provider: "opencode-go",
748
841
  model: "deepseek-v4-flash",
749
842
  signal: makeSignal(),
750
- messages: [userMessage("compact-1", [{ type: "text", text: "请压缩上下文" }, imageBlock])]
751
- };
843
+ messages: Object.freeze([userMessage("compact-1", [{ type: "text", text: "请压缩上下文" }, imageBlock])])
844
+ });
752
845
  const compactionStream = streamHandler(compactionOptions, emptyStream);
753
846
  assert.equal(typeof compactionStream[Symbol.asyncIterator], "function");
754
847
  await consumeStream(compactionStream);
755
848
  assert.equal(visionRequests, 0);
756
- assert.equal(JSON.stringify(compactionOptions.messages).includes('"type":"image"'), false);
757
- assert.match(compactionOptions.messages[0].content[1].text, /主模型已直接查看原图/);
849
+ assert.equal(JSON.stringify(compactionOptions.messages).includes('"type":"image"'), true);
850
+ assert.equal(rerouted.length, 1);
851
+ assert.equal(JSON.stringify(rerouted[0].messages).includes('"type":"image"'), false);
852
+ assert.match(rerouted[0].messages[0].content[1].text, /主模型已直接查看原图/);
758
853
 
759
854
  const normalOptions = {
760
855
  purpose: undefined,
@@ -767,6 +862,7 @@ test("compaction 请求不再重新识图,普通请求保持原样", async ()
767
862
  await consumeStream(streamHandler(normalOptions, emptyStream));
768
863
  assert.equal(JSON.stringify(normalOptions.messages), before);
769
864
  assert.equal(visionRequests, 0);
865
+ assert.equal(rerouted.length, 1);
770
866
  } finally {
771
867
  globalThis.fetch = previousFetch;
772
868
  }
@@ -784,6 +880,7 @@ test("压缩优先复用主模型已经拿到的视觉观察", async () => {
784
880
  };
785
881
  const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
786
882
  let streamHandler;
883
+ const rerouted = [];
787
884
  const session = {
788
885
  deriveMessages: () => [rewritten]
789
886
  };
@@ -795,18 +892,22 @@ test("压缩优先复用主模型已经拿到的视觉观察", async () => {
795
892
  }
796
893
  },
797
894
  llm: {
798
- resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
895
+ resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] }),
896
+ stream(options) {
897
+ rerouted.push(options);
898
+ return emptyStream();
899
+ }
799
900
  },
800
901
  logger: { error() {} },
801
- effect() {},
902
+ effect(factory) { factory(); },
802
903
  on(name, handler) {
803
904
  if (name === "llm/stream") streamHandler = handler;
804
905
  },
805
906
  inject(deps, cb) {
806
907
  if (deps[0] === "settings") {
807
- cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect() {} });
908
+ cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect(factory) { factory(); } });
808
909
  } else {
809
- cb({ webServer: { register: () => () => {} }, effect() {} });
910
+ cb({ webServer: { register: () => () => {} }, effect(factory) { factory(); } });
810
911
  }
811
912
  },
812
913
  get() {
@@ -816,18 +917,21 @@ test("压缩优先复用主模型已经拿到的视觉观察", async () => {
816
917
 
817
918
  try {
818
919
  apply(ctx, { ...config, mode: "auto" });
819
- const options = {
920
+ const options = Object.freeze({
820
921
  purpose: "compaction",
821
922
  sessionId: "session-reuse",
822
923
  provider: "opencode-go",
823
924
  model: "deepseek-v4-flash",
824
925
  signal: makeSignal(),
825
- messages: [original]
826
- };
926
+ messages: Object.freeze([original])
927
+ });
827
928
  await consumeStream(streamHandler(options, emptyStream));
828
929
  assert.equal(visionRequests, 0);
829
- assert.equal(options.messages[0], rewritten);
830
- assert.equal(JSON.stringify(options.messages).includes('"type":"image"'), false);
930
+ assert.equal(options.messages[0], original);
931
+ assert.equal(JSON.stringify(options.messages).includes('"type":"image"'), true);
932
+ assert.equal(rerouted.length, 1);
933
+ assert.equal(rerouted[0].messages[0], rewritten);
934
+ assert.equal(JSON.stringify(rerouted[0].messages).includes('"type":"image"'), false);
831
935
  } finally {
832
936
  globalThis.fetch = previousFetch;
833
937
  }
@@ -848,15 +952,15 @@ test("compaction 请求在 mode=never 时不拦截图片", async () => {
848
952
  resolveModelInfo: async () => ({ provider: "opencode-go", id: "deepseek-v4-flash", name: "deepseek-v4-flash", inputModalities: ["text"] })
849
953
  },
850
954
  logger: { error() {} },
851
- effect() {},
955
+ effect(factory) { factory(); },
852
956
  on(name, handler) {
853
957
  if (name === "llm/stream") streamHandler = handler;
854
958
  },
855
959
  inject(deps, cb) {
856
960
  if (deps[0] === "settings") {
857
- cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect() {} });
961
+ cb({ settings: { register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} }) }, effect(factory) { factory(); } });
858
962
  } else {
859
- cb({ webServer: { register: () => () => {} }, effect() {} });
963
+ cb({ webServer: { register: () => () => {} }, effect(factory) { factory(); } });
860
964
  }
861
965
  },
862
966
  get() {
@@ -959,12 +1063,21 @@ test("usage 记录失败调用并带错误信息", async () => {
959
1063
  }
960
1064
  });
961
1065
 
962
- test("npm 包声明可安装的 DSH bundle 和市场关键词", async () => {
1066
+ test("npm 包声明 DSH bundle、运行时范围和逐版本兼容状态", async () => {
963
1067
  const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
964
1068
  const patch = await readFile(new URL("../cordis.patch.yml", import.meta.url), "utf8");
965
1069
 
1070
+ assert.equal(manifest.version, "0.10.0");
966
1071
  assert.equal(manifest.dsh?.bundle?.patch, "./cordis.patch.yml");
1072
+ assert.equal(manifest.dsh?.compatibility?.dsh, ">=0.1.1-rc.2 <0.2.0");
1073
+ assert.deepEqual(manifest.dsh?.compatibility?.dshReleases, {
1074
+ "0.1.2-alpha.3": "compatible",
1075
+ "0.1.2-alpha.4": "compatible",
1076
+ "0.1.2-alpha.5": "compatible"
1077
+ });
1078
+ assert.equal(manifest.engines?.node, ">=22.13.0");
967
1079
  assert.ok(manifest.files.includes("cordis.patch.yml"));
1080
+ assert.ok(manifest.files.includes("CHANGELOG.md"));
968
1081
  assert.ok(manifest.keywords.includes("dsh-plugin"));
969
1082
  assert.match(patch, /id:\s*vision-fallback/);
970
1083
  assert.match(patch, /name:\s*['"]dsh-vision-fallback['"]/);