@modusensus/dsh-mneme 0.8.14 → 0.8.15

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.
Files changed (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -673
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -843
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -503
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -229
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -215
  65. package/lib/service.js +2663 -2605
  66. package/lib/settings.js +517 -511
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +118 -118
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -36
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -673
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -843
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -503
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -229
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -215
  137. package/src/service.js +2663 -2605
  138. package/src/settings.js +517 -511
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1690
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -60
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -77
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -367
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -121
  246. package/test/serve.test.js +196 -196
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -751
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
@@ -1,752 +1,752 @@
1
- // Regression for issue #9, extended by issue #135 (建议 3/4/5):
2
- // - B: dreamMaxTokens cap widened (min 256, max 131072) so large memory
3
- // libraries no longer starve the consolidation output. #135 raises the
4
- // DEFAULT to 131072 too — 32768 was exactly what thinking-model reasoning
5
- // burnt, leaving an empty body.
6
- // - A: dreamReasoningEffort / sleepReasoningEffort pass-through. Explicit
7
- // 'none' omits the reasoningEffort field entirely (provider default
8
- // applies); low/medium/high are forwarded verbatim on every dream /
9
- // sleep LLM call. #135: UNSET now resolves to the LOWEST effort the model
10
- // declares (the old default 'none' let the harness substitute the model's
11
- // defaultEffort, burning the budget on reasoning). Asserted by capturing
12
- // the options each llm.stream() sees.
13
- import test from "node:test";
14
- import assert from "node:assert/strict";
15
- import { Config } from "../src/config.js";
16
- import { createDreamScheduler, parseReceipt } from "../src/dream.js";
17
- import { runSleep } from "../src/dream/sleep.js";
18
- import { createStore } from "../src/store.js";
19
- import { createService } from "../src/service.js";
20
- import { createVectorIndex } from "../src/vector-index.js";
21
- import { createEntityStreamAdapter } from "../src/index.js";
22
-
23
- const embedder = {
24
- embedSingle: async () => [1, 0, 0],
25
- embed: async () => [1, 0, 0],
26
- schedule: () => {},
27
- modelHash: "mock#1",
28
- dimension: 3
29
- };
30
-
31
- // ---------------------------------------------------------------- config schema
32
-
33
- test("issue#135: dreamMaxTokens defaults to 131072 and accepts values up to 131072", () => {
34
- assert.equal(Config({}).dreamMaxTokens, 131072, "old default 32768 was exactly what thinking-model reasoning burnt (Issue #135 建议 3)");
35
- assert.equal(Config({ dreamMaxTokens: 131072 }).dreamMaxTokens, 131072, "upper bound accepted");
36
- assert.equal(Config({ dreamMaxTokens: 65536 }).dreamMaxTokens, 65536, "intermediate value accepted");
37
- });
38
-
39
- test("issue#9: dreamMaxTokens clamps to [256, 131072], out-of-range values are rejected", () => {
40
- assert.equal(Config({ dreamMaxTokens: 256 }).dreamMaxTokens, 256, "lower bound accepted");
41
- assert.equal(Config({ dreamMaxTokens: 100000 }).dreamMaxTokens, 100000, "raised default tier accepted");
42
- assert.throws(() => Config({ dreamMaxTokens: 255 }), "below min rejected");
43
- assert.throws(() => Config({ dreamMaxTokens: 131073 }), "above max rejected");
44
- assert.throws(() => Config({ dreamMaxTokens: 0 }), "zero rejected");
45
- });
46
-
47
- test("issue#135: reasoningEffort unset = undefined (auto-lowest at resolve time); explicit values preserved", () => {
48
- const cfg = Config({});
49
- assert.equal(cfg.dreamReasoningEffort, undefined, "unset must be distinguishable from explicit 'none'");
50
- assert.equal(cfg.sleepReasoningEffort, undefined);
51
- // issue #315: the distill knob defaults to 'none' (entity-extraction shape,
52
- // no auto-lowest resolution — distill keeps its current behavior until the
53
- // user opts in).
54
- assert.equal(cfg.summarizeReasoningEffort, "none", "distill effort defaults to 'none'");
55
- assert.equal(Config({ summarizeReasoningEffort: "off" }).summarizeReasoningEffort, "off");
56
- assert.equal(Config({ dreamReasoningEffort: "none" }).dreamReasoningEffort, "none", "explicit 'none' kept");
57
- assert.equal(Config({ dreamReasoningEffort: "off" }).dreamReasoningEffort, "off", "'off' now a valid explicit choice");
58
- assert.equal(Config({ sleepReasoningEffort: "medium" }).sleepReasoningEffort, "medium");
59
- assert.throws(() => Config({ dreamReasoningEffort: "bogus" }), "invalid effort rejected");
60
- assert.throws(() => Config({ sleepReasoningEffort: "ultra" }), "invalid effort rejected");
61
- assert.throws(() => Config({ summarizeReasoningEffort: "max" }), "invalid distill effort rejected");
62
- });
63
-
64
- // ---------------------------------------------------------------- dream passthrough
65
-
66
- /** dream ctx that records every llm.stream() call's options for inspection. */
67
- function dreamCtx({ onConsolidation, summaryText = "记忆库总览:用户偏好中文。", captured = [] } = {}) {
68
- return {
69
- logger: { warn: () => {} },
70
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
71
- llm: {
72
- async *stream(options) {
73
- captured.push(options);
74
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
75
- if (userText.startsWith("id=")) {
76
- yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
77
- } else {
78
- yield { type: "text-delta", index: 0, text: summaryText };
79
- }
80
- yield { type: "finish", reason: { kind: "stop" } };
81
- }
82
- }
83
- };
84
- }
85
-
86
- test("issue#135: dream omits reasoningEffort when unset without a capability API and still consolidates (applied>0)", async () => {
87
- const store = createStore(":memory:");
88
- const service = createService({ store, mirror: null, config: {} });
89
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
90
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
91
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
92
- const captured = [];
93
- const ctx = dreamCtx({
94
- captured,
95
- onConsolidation: () => JSON.stringify([
96
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
97
- ])
98
- });
99
- const result = await dream.runDream(ctx, service, {});
100
- assert.equal(result.ok, true);
101
- assert.ok(result.applied > 0, "end-to-end dream run still lands changes");
102
- assert.equal(captured.length, 2, "consolidation + summary both hit the LLM");
103
- for (const options of captured) {
104
- assert.equal("reasoningEffort" in options, false, `unset without capability API must not forward reasoningEffort (${options.purpose})`);
105
- }
106
- store.close();
107
- });
108
-
109
- test("issue#135: unset effort resolves to the LOWEST supported effort, never the poison default", async () => {
110
- const store = createStore(":memory:");
111
- const service = createService({ store, mirror: null, config: {} });
112
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
113
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
114
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
115
- const captured = [];
116
- const ctx = dreamCtx({
117
- captured,
118
- onConsolidation: () => JSON.stringify([
119
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
120
- ])
121
- });
122
- // v4-flash 系能力报告:声明 off/low/high/max、defaultEffort=high。旧默认
123
- // (省略字段)让 harness 顶上 high → 推理烧光预算返回空体;现在未配置应
124
- // 自动取最低档 off,而不是服务商默认。
125
- ctx.llm.resolveModelInfo = async (provider, model) => ({
126
- provider,
127
- model,
128
- reasoning: { efforts: [{ id: "high" }, { id: "off" }, { id: "low" }, { id: "max" }], defaultEffort: "high" }
129
- });
130
- const result = await dream.runDream(ctx, service, {});
131
- assert.equal(result.ok, true);
132
- assert.ok(result.applied > 0, "consolidation lands changes");
133
- assert.equal(captured.length, 2, "consolidation + summary both hit the LLM");
134
- for (const options of captured) {
135
- assert.equal(options.reasoningEffort, "off", `unset resolves to the lowest declared effort on ${options.purpose}`);
136
- }
137
- store.close();
138
- });
139
-
140
- test("issue#135: explicit 'none' still omits the field even when the capability API is available", async () => {
141
- const store = createStore(":memory:");
142
- const service = createService({ store, mirror: null, config: {} });
143
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
144
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
145
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
146
- const captured = [];
147
- const ctx = dreamCtx({
148
- captured,
149
- onConsolidation: () => JSON.stringify([
150
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
151
- ])
152
- });
153
- ctx.llm.resolveModelInfo = async (provider, model) => ({
154
- provider,
155
- model,
156
- reasoning: { efforts: [{ id: "off" }, { id: "low" }], defaultEffort: "high" }
157
- });
158
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "none" });
159
- assert.equal(result.ok, true);
160
- for (const options of captured) {
161
- assert.equal("reasoningEffort" in options, false, `explicit 'none' is a user decision to omit (${options.purpose})`);
162
- }
163
- store.close();
164
- });
165
-
166
- test("issue#9: dream forwards dreamReasoningEffort on both LLM calls", async () => {
167
- const store = createStore(":memory:");
168
- const service = createService({ store, mirror: null, config: {} });
169
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
170
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
171
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
172
- const captured = [];
173
- const ctx = dreamCtx({
174
- captured,
175
- onConsolidation: () => JSON.stringify([
176
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
177
- ])
178
- });
179
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
180
- assert.equal(result.ok, true);
181
- assert.equal(captured.length, 2);
182
- for (const options of captured) {
183
- assert.equal(options.reasoningEffort, "high", `reasoningEffort forwarded on ${options.purpose}`);
184
- }
185
- store.close();
186
- });
187
-
188
- test("issue#9: dreamMaxTokens is forwarded as maxTokens on consolidation and summary calls", async () => {
189
- const store = createStore(":memory:");
190
- const service = createService({ store, mirror: null, config: {} });
191
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
192
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
193
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
194
- const captured = [];
195
- const ctx = dreamCtx({
196
- captured,
197
- onConsolidation: () => JSON.stringify([
198
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
199
- ])
200
- });
201
- const result = await dream.runDream(ctx, service, { dreamMaxTokens: 100000 });
202
- assert.equal(result.ok, true);
203
- assert.equal(captured.length, 2, "consolidation + summary both hit the LLM");
204
- for (const options of captured) {
205
- assert.equal(options.maxTokens, 100000, `raised budget forwarded on ${options.purpose}`);
206
- }
207
- store.close();
208
- });
209
-
210
- test("issue#9: thinking-model empty body (no text emitted, budget burnt on reasoning) fails as no json array", async () => {
211
- const store = createStore(":memory:");
212
- const service = createService({ store, mirror: null, config: {} });
213
- service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
214
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
215
- const ctx = dreamCtx({ onConsolidation: () => "" }); // 思考型模型把预算烧光 → 无正文
216
- const result = await dream.runDream(ctx, service, { dreamMaxTokens: 32768 });
217
- assert.equal(result.ok, false, "empty body is a hard failure, never faked ok");
218
- assert.match(result.error, /no json array/);
219
- const run = store.listDreamRuns()[0];
220
- assert.equal(run.status, "failed", "audit row records failed");
221
- assert.match(run.error, /no json array/, "audit error_message carries the empty-body cause");
222
- assert.equal(parseReceipt(run.receipt).status, "failed", "receipt records failed");
223
- store.close();
224
- });
225
-
226
- test("legal empty decision list [] is a no-op success, not a failure", async () => {
227
- // 真实模型在记忆无冗余时合法输出 [](CONSOLIDATION_PROMPT 允许"无问题无需输出"),
228
- // 此前被 validateDecisions 判 failed → 审计表反复失败。现在应 ok:true、applied 0、
229
- // 审计记 ok,且 summary 照常产出。
230
- const store = createStore(":memory:");
231
- const service = createService({ store, mirror: null, config: {} });
232
- service.saveWithDedupe({ type: "project", title: "插件", content: "内容", importance: 3 });
233
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
234
- const ctx = dreamCtx({ onConsolidation: () => "[]" }); // 模型:无需合并
235
- const result = await dream.runDream(ctx, service, { dreamMaxTokens: 32768 });
236
- assert.equal(result.ok, true, "empty [] is a valid no-op, never failed");
237
- assert.equal(result.applied, 0, "no decisions to apply");
238
- assert.equal(result.summary, true, "summary still produced");
239
- const run = store.listDreamRuns()[0];
240
- assert.equal(run.status, "ok", "audit row records ok, not failed");
241
- assert.equal(parseReceipt(run.receipt).status, "ok", "receipt records ok");
242
- store.close();
243
- });
244
-
245
- test("issue#25: dreamProvider/dreamModel config wins over the agentDefaultModel route", async () => {
246
- const store = createStore(":memory:");
247
- const service = createService({ store, mirror: null, config: {} });
248
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
249
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
250
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
251
- const captured = [];
252
- const ctx = dreamCtx({
253
- captured,
254
- onConsolidation: () => JSON.stringify([
255
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
256
- ])
257
- });
258
- // dreamCtx's agentDefaultModel resolves (mock:mock-model), but the explicit
259
- // config route must win — otherwise dreamProvider/dreamModel is dead code in
260
- // a standard DSH install and the dream can never be moved off a thinking model.
261
- const result = await dream.runDream(ctx, service, { dreamProvider: "volcano", dreamModel: "deepseek-v3" });
262
- assert.equal(result.ok, true);
263
- assert.ok(captured.length >= 2, "consolidation + summary both hit the LLM");
264
- for (const options of captured) {
265
- assert.equal(options.provider, "volcano");
266
- assert.equal(options.model, "deepseek-v3", "config route wins over agentDefaultModel (mock:mock-model)");
267
- }
268
- store.close();
269
- });
270
-
271
- test("issue#9: rejected reasoningEffort retries once without it and still consolidates", async () => {
272
- const store = createStore(":memory:");
273
- const service = createService({ store, mirror: null, config: {} });
274
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
275
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
276
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
277
- const calls = [];
278
- const ctx = {
279
- logger: { warn: () => {} },
280
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
281
- llm: {
282
- async *stream(options) {
283
- calls.push(options);
284
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
285
- if (userText.startsWith("id=")) {
286
- // First attempt forwards reasoningEffort: the provider rejects it.
287
- if (options.reasoningEffort) {
288
- throw new Error("UNSUPPORTED_REASONING_EFFORT: DeepSeek does not support reasoning effort \"low\"");
289
- }
290
- yield { type: "text-delta", index: 0, text: JSON.stringify([
291
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
292
- ]) };
293
- } else {
294
- yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
295
- }
296
- yield { type: "finish", reason: { kind: "stop" } };
297
- }
298
- }
299
- };
300
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
301
- assert.equal(result.ok, true, "run survives the effort rejection via the fallback retry");
302
- assert.ok(result.applied > 0, "consolidation still lands changes");
303
- assert.equal(calls.length, 3, "consolidation tried (rejected) + retried without effort + summary");
304
- assert.equal(calls[0].reasoningEffort, "low", "first consolidation attempt forwards the effort");
305
- assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
306
- store.close();
307
- });
308
-
309
- // ---------------------------------------------------------------- sleep passthrough
310
-
311
- function sleepSetup() {
312
- const store = createStore(":memory:");
313
- const service = createService({ store, mirror: null, config: {} });
314
- const vectorIndex = createVectorIndex({ store });
315
- service.setEmbedder(embedder);
316
- service.setVectorIndex(vectorIndex);
317
- return { store, service, vectorIndex };
318
- }
319
-
320
- function baseConfig(overrides = {}) {
321
- return {
322
- sleepModeEnabled: true,
323
- sleepIdleMinutes: 5,
324
- sleepMinIntervalHours: 8,
325
- sleepConflictStrictness: "normal",
326
- sleepArchiveDays: 30,
327
- sleepCompressDays: 90,
328
- sleepPatternMinMemories: 10,
329
- sleepMaxPatternPerRun: 3,
330
- ...overrides
331
- };
332
- }
333
-
334
- /** sleep ctx that records every llm.stream() call's options. */
335
- function sleepCtx(onConsolidation, selection = { provider: "mock", model: "sleep-model" }, captured = []) {
336
- return {
337
- logger: { warn: () => {}, info: () => {} },
338
- agentDefaultModel: { currentSelection: () => selection },
339
- llm: {
340
- async *stream(options) {
341
- captured.push(options);
342
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
343
- yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
344
- yield { type: "finish", reason: { kind: "stop" } };
345
- }
346
- }
347
- };
348
- }
349
-
350
- test("issue#9: sleep forwards sleepReasoningEffort on its LLM passes", async () => {
351
- const { store, service, vectorIndex } = sleepSetup();
352
- const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
353
- const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
354
- vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
355
- vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
356
- const captured = [];
357
- const ctx = sleepCtx(
358
- (userText) => userText.startsWith("候选冲突")
359
- ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
360
- : "[]",
361
- { provider: "mock", model: "sleep-model" },
362
- captured
363
- );
364
- const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "medium" }), ctx.logger, { embedder, vectorIndex }, null);
365
- assert.equal(result.status, "ok");
366
- assert.ok(captured.length >= 2, "conflict + pattern passes both hit the LLM");
367
- for (const options of captured) {
368
- assert.equal(options.reasoningEffort, "medium", `reasoningEffort forwarded on ${options.purpose}`);
369
- }
370
- store.close();
371
- });
372
-
373
- test("issue#135: sleep unset effort also resolves to the lowest supported", async () => {
374
- const { store, service, vectorIndex } = sleepSetup();
375
- const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
376
- const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
377
- vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
378
- vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
379
- const captured = [];
380
- const ctx = sleepCtx(
381
- (userText) => userText.startsWith("候选冲突")
382
- ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
383
- : "[]",
384
- { provider: "mock", model: "sleep-model" },
385
- captured
386
- );
387
- ctx.llm.resolveModelInfo = async (provider, model) => ({
388
- provider,
389
- model,
390
- reasoning: { efforts: [{ id: "low" }, { id: "high" }], defaultEffort: "high" }
391
- });
392
- const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
393
- assert.equal(result.status, "ok");
394
- assert.ok(captured.length >= 2, "conflict + pattern passes both hit the LLM");
395
- for (const options of captured) {
396
- assert.equal(options.reasoningEffort, "low", `unset sleep effort resolves to the lowest declared (${options.purpose})`);
397
- }
398
- store.close();
399
- });
400
-
401
- // ------------------------------------------------------------------ stream-level rejection
402
- // dsh-llm rc.1 converts adapter-stage failures (including the provider's
403
- // UNSUPPORTED_REASONING_EFFORT throw from resolveCallWithInfo) into a terminal
404
- // error finish chunk inside adapterStream — the rejection NEVER reaches our
405
- // catch. The v0.7.16 throw-based fallback was therefore dead code for the
406
- // stream path; these tests pin the finish-chunk-based fallback.
407
-
408
- test("rc.1 stream-level effort rejection (error finish chunk) also triggers the no-effort retry", async () => {
409
- const store = createStore(":memory:");
410
- const service = createService({ store, mirror: null, config: {} });
411
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
412
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
413
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
414
- const calls = [];
415
- const warnings = [];
416
- const ctx = {
417
- logger: { warn: (m) => warnings.push(String(m)) },
418
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
419
- llm: {
420
- async *stream(options) {
421
- calls.push(options);
422
- if (options.reasoningEffort) {
423
- yield {
424
- type: "finish",
425
- reason: {
426
- kind: "error",
427
- failure: {
428
- code: "UNSUPPORTED_REASONING_EFFORT",
429
- message: 'provider "mock" model "mock-model" does not support reasoning effort "low"'
430
- }
431
- }
432
- };
433
- return;
434
- }
435
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
436
- if (userText.startsWith("id=")) {
437
- yield { type: "text-delta", index: 0, text: JSON.stringify([
438
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
439
- ]) };
440
- } else {
441
- yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
442
- }
443
- yield { type: "finish", reason: { kind: "stop" } };
444
- }
445
- }
446
- };
447
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
448
- assert.equal(result.ok, true, "run survives the stream-level effort rejection");
449
- assert.ok(result.applied > 0, "consolidation still lands changes");
450
- assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
451
- assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
452
- assert.ok(warnings.some((w) => w.includes("rejected via stream")), "the stream-level rejection is logged");
453
- store.close();
454
- });
455
-
456
- test("non-effort stream failures are not retried and the finish-chunk cause reaches the audit row", async () => {
457
- const store = createStore(":memory:");
458
- const service = createService({ store, mirror: null, config: {} });
459
- service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
460
- const calls = [];
461
- const ctx = {
462
- logger: { warn: () => {} },
463
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
464
- llm: {
465
- async *stream(options) {
466
- calls.push(options);
467
- yield { type: "finish", reason: { kind: "error", failure: { code: "PROVIDER_GONE", message: "provider mock is not registered" } } };
468
- }
469
- }
470
- };
471
- const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
472
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
473
- assert.equal(result.ok, false);
474
- assert.equal(result.error, "llm failed", "the run error stays the stable short string");
475
- assert.equal(calls.length, 1, "no blind retry when the stream failure is not an effort rejection");
476
- const row = service.listLlmAudits().find((r) => r.operation_type === "dream_consolidate");
477
- assert.ok(row && row.status === "error", "failed consolidation still audited");
478
- assert.ok(
479
- String(row.error_message).includes("PROVIDER_GONE") && String(row.error_message).includes("provider mock is not registered"),
480
- "audit error_message carries the finish-chunk cause"
481
- );
482
- store.close();
483
- });
484
-
485
- test("sleep passes the stream failure accessor so a stream-level effort rejection retries", async () => {
486
- const { store, service, vectorIndex } = sleepSetup();
487
- const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
488
- const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
489
- vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
490
- vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
491
- const captured = [];
492
- const ctx = sleepCtx(null, { provider: "mock", model: "sleep-model" }, captured);
493
- ctx.llm.stream = async function* (options) {
494
- captured.push(options);
495
- if (options.reasoningEffort) {
496
- yield {
497
- type: "finish",
498
- reason: { kind: "error", failure: { code: "UNSUPPORTED_REASONING_EFFORT", message: 'provider "mock" model "sleep-model" does not support reasoning effort "low"' } }
499
- };
500
- return;
501
- }
502
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
503
- yield { type: "text-delta", index: 0, text: userText.startsWith("候选冲突")
504
- ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
505
- : "[]" };
506
- yield { type: "finish", reason: { kind: "stop" } };
507
- };
508
- const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "low" }), ctx.logger, { embedder, vectorIndex }, null);
509
- assert.equal(result.status, "ok", "sleep survives the stream-level effort rejection");
510
- assert.equal(captured[0].reasoningEffort, "low", "first conflict attempt forwards the effort");
511
- assert.equal("reasoningEffort" in captured[1], false, "conflict retry omits the rejected effort field");
512
- store.close();
513
- });
514
-
515
- // ------------------------------------------------------------------ defaultEffort trap
516
- // DSH Desktop's volcano-engine adapter declares reasoning.defaultEffort="low"
517
- // for a model that rejects "low", so omitting the field is NOT a safe retry —
518
- // the harness substitutes the poison default and fails again. resolveDreamEffort
519
- // queries resolveModelInfo up front and forwards a value that is actually in
520
- // the model's declared efforts, so the first attempt already carries a
521
- // supported effort and never trips UNSUPPORTED_REASONING_EFFORT.
522
-
523
- test("defaultEffort trap: configured 'low' remapped to the first supported effort when default is poison", async () => {
524
- const store = createStore(":memory:");
525
- const service = createService({ store, mirror: null, config: {} });
526
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
527
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
528
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
529
- const captured = [];
530
- const infoCalls = [];
531
- const ctx = dreamCtx({
532
- captured,
533
- onConsolidation: () => JSON.stringify([
534
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
535
- ])
536
- });
537
- // The adapter's capability report: defaultEffort "low" is NOT in efforts
538
- // (the model rejects it) — exactly the volcano-engine/deepseek-v4-flash trap.
539
- ctx.llm.resolveModelInfo = async (provider, model) => {
540
- infoCalls.push([provider, model]);
541
- return {
542
- provider,
543
- model,
544
- reasoning: {
545
- efforts: [{ id: "medium" }, { id: "high" }],
546
- defaultEffort: "low"
547
- }
548
- };
549
- };
550
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
551
- assert.equal(result.ok, true, "run succeeds without ever tripping the poison default");
552
- assert.ok(result.applied > 0, "consolidation lands changes");
553
- assert.deepEqual(infoCalls[0], ["mock", "mock-model"], "capability queried for the exact dream route");
554
- assert.equal(captured[0].reasoningEffort, "medium", "poison 'low' remapped to the first supported effort");
555
- assert.equal(captured[1].reasoningEffort, "medium", "summary pass uses the same resolved effort");
556
- store.close();
557
- });
558
-
559
- test("defaultEffort trap: model with no reasoning capability omits the field entirely", async () => {
560
- const store = createStore(":memory:");
561
- const service = createService({ store, mirror: null, config: {} });
562
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
563
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
564
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
565
- const captured = [];
566
- const ctx = dreamCtx({
567
- captured,
568
- onConsolidation: () => JSON.stringify([
569
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
570
- ])
571
- });
572
- // Non-thinking model (e.g. deepseek-v4-flash): adapter reports no reasoning
573
- // capability, so ANY explicit effort would be rejected — the helper must
574
- // drop it, which is the harness's safe "no reasoning" path.
575
- ctx.llm.resolveModelInfo = async () => ({ provider: "mock", model: "mock-model", reasoning: undefined });
576
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
577
- assert.equal(result.ok, true);
578
- for (const options of captured) {
579
- assert.equal("reasoningEffort" in options, false, "no reasoning capability -> effort omitted, never rejected");
580
- }
581
- store.close();
582
- });
583
-
584
- test("defaultEffort trap: configured effort supported is forwarded verbatim", async () => {
585
- const store = createStore(":memory:");
586
- const service = createService({ store, mirror: null, config: {} });
587
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
588
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
589
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
590
- const captured = [];
591
- const ctx = dreamCtx({
592
- captured,
593
- onConsolidation: () => JSON.stringify([
594
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
595
- ])
596
- });
597
- ctx.llm.resolveModelInfo = async () => ({
598
- provider: "mock",
599
- model: "mock-model",
600
- reasoning: { efforts: [{ id: "high" }, { id: "low" }], defaultEffort: "low" }
601
- });
602
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
603
- assert.equal(result.ok, true);
604
- for (const options of captured) {
605
- assert.equal(options.reasoningEffort, "high", "supported configured value untouched");
606
- }
607
- store.close();
608
- });
609
-
610
- test("defaultEffort trap: capability query failure falls back to configured effort (retry still guards)", async () => {
611
- const store = createStore(":memory:");
612
- const service = createService({ store, mirror: null, config: {} });
613
- const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
614
- const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
615
- const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
616
- const calls = [];
617
- const warnings = [];
618
- const ctx = {
619
- logger: { warn: (m) => warnings.push(String(m)) },
620
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
621
- llm: {
622
- async *stream(options) {
623
- calls.push(options);
624
- if (options.reasoningEffort) {
625
- throw new Error("UNSUPPORTED_REASONING_EFFORT: mock does not support reasoning effort \"high\"");
626
- }
627
- const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
628
- if (userText.startsWith("id=")) {
629
- yield { type: "text-delta", index: 0, text: JSON.stringify([
630
- { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
631
- ]) };
632
- } else {
633
- yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
634
- }
635
- yield { type: "finish", reason: { kind: "stop" } };
636
- },
637
- // Adapter knows nothing about the model — helper must not crash, and the
638
- // configured effort flows through so withEffortFallback still retries.
639
- resolveModelInfo: async () => { throw new Error("adapter not reachable"); }
640
- }
641
- };
642
- const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
643
- assert.equal(result.ok, true, "run succeeds via the no-effort retry");
644
- assert.equal(calls[0].reasoningEffort, "high", "configured effort forwarded when capability query fails");
645
- assert.equal("reasoningEffort" in calls[1], false, "rejected effort retried without the field");
646
- assert.ok(warnings.some((w) => w.includes("resolveModelInfo failed")), "capability-query failure is logged");
647
- store.close();
648
- });
649
-
650
- test("defaultEffort trap: sleep conflict pass remaps a poison effort too", async () => {
651
- const { store, service, vectorIndex } = sleepSetup();
652
- const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
653
- const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
654
- vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
655
- vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
656
- const captured = [];
657
- const ctx = sleepCtx(
658
- (userText) => userText.startsWith("候选冲突")
659
- ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
660
- : "[]",
661
- { provider: "mock", model: "sleep-model" },
662
- captured
663
- );
664
- ctx.llm.resolveModelInfo = async (provider, model) => ({
665
- provider,
666
- model,
667
- reasoning: { efforts: [{ id: "medium" }, { id: "high" }], defaultEffort: "low" }
668
- });
669
- const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "low" }), ctx.logger, { embedder, vectorIndex }, null);
670
- assert.equal(result.status, "ok");
671
- assert.ok(captured.length >= 2, "conflict + pattern passes both hit the LLM");
672
- for (const options of captured) {
673
- assert.equal(options.reasoningEffort, "medium", "poison 'low' remapped on sleep passes too");
674
- }
675
- store.close();
676
- });
677
-
678
- // ------------------------------------------------- entity extraction adapter (issue #108/#109)
679
- // The streamEntityText adapter lives in index.js; it maps the extractor's
680
- // options onto a dsh-llm stream route and retries once without the effort
681
- // when the first attempt is rejected. These tests drive the real exported
682
- // factory, not a mock of it.
683
-
684
- test("issue#109: entity extraction effort rejection retries once without the effort", async () => {
685
- const calls = [];
686
- const warnings = [];
687
- const streamEntityText = createEntityStreamAdapter({
688
- llm: {
689
- async *stream(options) {
690
- calls.push(options);
691
- // First attempt carries the effort: the provider rejects it via an
692
- // error finish chunk (the realistic stream-level rejection).
693
- if (options.reasoningEffort) {
694
- yield { type: "finish", reason: { kind: "error", message: "UNSUPPORTED_REASONING_EFFORT" } };
695
- return;
696
- }
697
- yield { type: "text-delta", index: 0, text: "{\"entities\":[{\"name\":\"张三\",\"type\":\"person\",\"attrs\":[{\"key\":\"职业\",\"value\":\"工程师\",\"confidence\":0.9}]}],\"relations\":[]}" };
698
- yield { type: "finish", reason: { kind: "stop" } };
699
- }
700
- },
701
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
702
- logger: { warn: (m) => warnings.push(String(m)) }
703
- });
704
- const text = await streamEntityText(
705
- [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
706
- { reasoningEffort: "low" }
707
- );
708
- assert.equal(calls.length, 2, "attempt (rejected) + retry without effort");
709
- assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
710
- assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
711
- assert.equal(calls[0].provider, "mock", "route resolved from agentDefaultModel");
712
- assert.equal(calls[0].model, "mock-model", "route model from agentDefaultModel");
713
- assert.equal(calls[0].maxTokens, 4096, "extraction caps its output");
714
- assert.ok(text.includes("张三"), "retry stream text is returned");
715
- assert.ok(warnings.some((w) => w.includes("rejected, retrying without it")), "rejection is logged");
716
- });
717
-
718
- test("issue#109: entity extraction never retries blindly without an effort configured", async () => {
719
- const calls = [];
720
- const streamEntityText = createEntityStreamAdapter({
721
- llm: {
722
- async *stream(options) {
723
- calls.push(options);
724
- yield { type: "finish", reason: { kind: "error", message: "overloaded" } };
725
- }
726
- },
727
- agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
728
- logger: { warn: () => {} }
729
- });
730
- const text = await streamEntityText([{ role: "user", content: [] }], {});
731
- assert.equal(text, undefined, "failure yields no text");
732
- assert.equal(calls.length, 1, "no blind retry when no effort was requested");
733
- });
734
-
735
- test("issue#109: entity extraction explicit provider/model win over the default route", async () => {
736
- const calls = [];
737
- const streamEntityText = createEntityStreamAdapter({
738
- llm: {
739
- async *stream(options) {
740
- calls.push(options);
741
- yield { type: "text-delta", index: 0, text: "{}" };
742
- yield { type: "finish", reason: { kind: "stop" } };
743
- }
744
- },
745
- agentDefaultModel: { currentSelection: () => ({ provider: "default", model: "default-model" }) },
746
- logger: { warn: () => {} }
747
- });
748
- await streamEntityText([{ role: "user", content: [] }], { provider: "volcano", model: "deepseek-v3" });
749
- assert.equal(calls.length, 1);
750
- assert.equal(calls[0].provider, "volcano", "explicit provider beats the default");
751
- assert.equal(calls[0].model, "deepseek-v3", "explicit model beats the default");
752
- });
1
+ // Regression for issue #9, extended by issue #135 (建议 3/4/5):
2
+ // - B: dreamMaxTokens cap widened (min 256, max 131072) so large memory
3
+ // libraries no longer starve the consolidation output. #135 raises the
4
+ // DEFAULT to 131072 too — 32768 was exactly what thinking-model reasoning
5
+ // burnt, leaving an empty body.
6
+ // - A: dreamReasoningEffort / sleepReasoningEffort pass-through. Explicit
7
+ // 'none' omits the reasoningEffort field entirely (provider default
8
+ // applies); low/medium/high are forwarded verbatim on every dream /
9
+ // sleep LLM call. #135: UNSET now resolves to the LOWEST effort the model
10
+ // declares (the old default 'none' let the harness substitute the model's
11
+ // defaultEffort, burning the budget on reasoning). Asserted by capturing
12
+ // the options each llm.stream() sees.
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { Config } from "../src/config.js";
16
+ import { createDreamScheduler, parseReceipt } from "../src/dream.js";
17
+ import { runSleep } from "../src/dream/sleep.js";
18
+ import { createStore } from "../src/store.js";
19
+ import { createService } from "../src/service.js";
20
+ import { createVectorIndex } from "../src/vector-index.js";
21
+ import { createEntityStreamAdapter } from "../src/index.js";
22
+
23
+ const embedder = {
24
+ embedSingle: async () => [1, 0, 0],
25
+ embed: async () => [1, 0, 0],
26
+ schedule: () => {},
27
+ modelHash: "mock#1",
28
+ dimension: 3
29
+ };
30
+
31
+ // ---------------------------------------------------------------- config schema
32
+
33
+ test("issue#135: dreamMaxTokens defaults to 131072 and accepts values up to 131072", () => {
34
+ assert.equal(Config({}).dreamMaxTokens, 131072, "old default 32768 was exactly what thinking-model reasoning burnt (Issue #135 建议 3)");
35
+ assert.equal(Config({ dreamMaxTokens: 131072 }).dreamMaxTokens, 131072, "upper bound accepted");
36
+ assert.equal(Config({ dreamMaxTokens: 65536 }).dreamMaxTokens, 65536, "intermediate value accepted");
37
+ });
38
+
39
+ test("issue#9: dreamMaxTokens clamps to [256, 131072], out-of-range values are rejected", () => {
40
+ assert.equal(Config({ dreamMaxTokens: 256 }).dreamMaxTokens, 256, "lower bound accepted");
41
+ assert.equal(Config({ dreamMaxTokens: 100000 }).dreamMaxTokens, 100000, "raised default tier accepted");
42
+ assert.throws(() => Config({ dreamMaxTokens: 255 }), "below min rejected");
43
+ assert.throws(() => Config({ dreamMaxTokens: 131073 }), "above max rejected");
44
+ assert.throws(() => Config({ dreamMaxTokens: 0 }), "zero rejected");
45
+ });
46
+
47
+ test("issue#135: reasoningEffort unset = undefined (auto-lowest at resolve time); explicit values preserved", () => {
48
+ const cfg = Config({});
49
+ assert.equal(cfg.dreamReasoningEffort, undefined, "unset must be distinguishable from explicit 'none'");
50
+ assert.equal(cfg.sleepReasoningEffort, undefined);
51
+ // issue #315: the distill knob defaults to 'none' (entity-extraction shape,
52
+ // no auto-lowest resolution — distill keeps its current behavior until the
53
+ // user opts in).
54
+ assert.equal(cfg.summarizeReasoningEffort, "none", "distill effort defaults to 'none'");
55
+ assert.equal(Config({ summarizeReasoningEffort: "off" }).summarizeReasoningEffort, "off");
56
+ assert.equal(Config({ dreamReasoningEffort: "none" }).dreamReasoningEffort, "none", "explicit 'none' kept");
57
+ assert.equal(Config({ dreamReasoningEffort: "off" }).dreamReasoningEffort, "off", "'off' now a valid explicit choice");
58
+ assert.equal(Config({ sleepReasoningEffort: "medium" }).sleepReasoningEffort, "medium");
59
+ assert.throws(() => Config({ dreamReasoningEffort: "bogus" }), "invalid effort rejected");
60
+ assert.throws(() => Config({ sleepReasoningEffort: "ultra" }), "invalid effort rejected");
61
+ assert.throws(() => Config({ summarizeReasoningEffort: "max" }), "invalid distill effort rejected");
62
+ });
63
+
64
+ // ---------------------------------------------------------------- dream passthrough
65
+
66
+ /** dream ctx that records every llm.stream() call's options for inspection. */
67
+ function dreamCtx({ onConsolidation, summaryText = "记忆库总览:用户偏好中文。", captured = [] } = {}) {
68
+ return {
69
+ logger: { warn: () => {} },
70
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
71
+ llm: {
72
+ async *stream(options) {
73
+ captured.push(options);
74
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
75
+ if (userText.startsWith("id=")) {
76
+ yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
77
+ } else {
78
+ yield { type: "text-delta", index: 0, text: summaryText };
79
+ }
80
+ yield { type: "finish", reason: { kind: "stop" } };
81
+ }
82
+ }
83
+ };
84
+ }
85
+
86
+ test("issue#135: dream omits reasoningEffort when unset without a capability API and still consolidates (applied>0)", async () => {
87
+ const store = createStore(":memory:");
88
+ const service = createService({ store, mirror: null, config: {} });
89
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
90
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
91
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
92
+ const captured = [];
93
+ const ctx = dreamCtx({
94
+ captured,
95
+ onConsolidation: () => JSON.stringify([
96
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
97
+ ])
98
+ });
99
+ const result = await dream.runDream(ctx, service, {});
100
+ assert.equal(result.ok, true);
101
+ assert.ok(result.applied > 0, "end-to-end dream run still lands changes");
102
+ assert.equal(captured.length, 2, "consolidation + summary both hit the LLM");
103
+ for (const options of captured) {
104
+ assert.equal("reasoningEffort" in options, false, `unset without capability API must not forward reasoningEffort (${options.purpose})`);
105
+ }
106
+ store.close();
107
+ });
108
+
109
+ test("issue#135: unset effort resolves to the LOWEST supported effort, never the poison default", async () => {
110
+ const store = createStore(":memory:");
111
+ const service = createService({ store, mirror: null, config: {} });
112
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
113
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
114
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
115
+ const captured = [];
116
+ const ctx = dreamCtx({
117
+ captured,
118
+ onConsolidation: () => JSON.stringify([
119
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
120
+ ])
121
+ });
122
+ // v4-flash 系能力报告:声明 off/low/high/max、defaultEffort=high。旧默认
123
+ // (省略字段)让 harness 顶上 high → 推理烧光预算返回空体;现在未配置应
124
+ // 自动取最低档 off,而不是服务商默认。
125
+ ctx.llm.resolveModelInfo = async (provider, model) => ({
126
+ provider,
127
+ model,
128
+ reasoning: { efforts: [{ id: "high" }, { id: "off" }, { id: "low" }, { id: "max" }], defaultEffort: "high" }
129
+ });
130
+ const result = await dream.runDream(ctx, service, {});
131
+ assert.equal(result.ok, true);
132
+ assert.ok(result.applied > 0, "consolidation lands changes");
133
+ assert.equal(captured.length, 2, "consolidation + summary both hit the LLM");
134
+ for (const options of captured) {
135
+ assert.equal(options.reasoningEffort, "off", `unset resolves to the lowest declared effort on ${options.purpose}`);
136
+ }
137
+ store.close();
138
+ });
139
+
140
+ test("issue#135: explicit 'none' still omits the field even when the capability API is available", async () => {
141
+ const store = createStore(":memory:");
142
+ const service = createService({ store, mirror: null, config: {} });
143
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
144
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
145
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
146
+ const captured = [];
147
+ const ctx = dreamCtx({
148
+ captured,
149
+ onConsolidation: () => JSON.stringify([
150
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
151
+ ])
152
+ });
153
+ ctx.llm.resolveModelInfo = async (provider, model) => ({
154
+ provider,
155
+ model,
156
+ reasoning: { efforts: [{ id: "off" }, { id: "low" }], defaultEffort: "high" }
157
+ });
158
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "none" });
159
+ assert.equal(result.ok, true);
160
+ for (const options of captured) {
161
+ assert.equal("reasoningEffort" in options, false, `explicit 'none' is a user decision to omit (${options.purpose})`);
162
+ }
163
+ store.close();
164
+ });
165
+
166
+ test("issue#9: dream forwards dreamReasoningEffort on both LLM calls", async () => {
167
+ const store = createStore(":memory:");
168
+ const service = createService({ store, mirror: null, config: {} });
169
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
170
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
171
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
172
+ const captured = [];
173
+ const ctx = dreamCtx({
174
+ captured,
175
+ onConsolidation: () => JSON.stringify([
176
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
177
+ ])
178
+ });
179
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
180
+ assert.equal(result.ok, true);
181
+ assert.equal(captured.length, 2);
182
+ for (const options of captured) {
183
+ assert.equal(options.reasoningEffort, "high", `reasoningEffort forwarded on ${options.purpose}`);
184
+ }
185
+ store.close();
186
+ });
187
+
188
+ test("issue#9: dreamMaxTokens is forwarded as maxTokens on consolidation and summary calls", async () => {
189
+ const store = createStore(":memory:");
190
+ const service = createService({ store, mirror: null, config: {} });
191
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
192
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
193
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
194
+ const captured = [];
195
+ const ctx = dreamCtx({
196
+ captured,
197
+ onConsolidation: () => JSON.stringify([
198
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
199
+ ])
200
+ });
201
+ const result = await dream.runDream(ctx, service, { dreamMaxTokens: 100000 });
202
+ assert.equal(result.ok, true);
203
+ assert.equal(captured.length, 2, "consolidation + summary both hit the LLM");
204
+ for (const options of captured) {
205
+ assert.equal(options.maxTokens, 100000, `raised budget forwarded on ${options.purpose}`);
206
+ }
207
+ store.close();
208
+ });
209
+
210
+ test("issue#9: thinking-model empty body (no text emitted, budget burnt on reasoning) fails as no json array", async () => {
211
+ const store = createStore(":memory:");
212
+ const service = createService({ store, mirror: null, config: {} });
213
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
214
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
215
+ const ctx = dreamCtx({ onConsolidation: () => "" }); // 思考型模型把预算烧光 → 无正文
216
+ const result = await dream.runDream(ctx, service, { dreamMaxTokens: 32768 });
217
+ assert.equal(result.ok, false, "empty body is a hard failure, never faked ok");
218
+ assert.match(result.error, /no json array/);
219
+ const run = store.listDreamRuns()[0];
220
+ assert.equal(run.status, "failed", "audit row records failed");
221
+ assert.match(run.error, /no json array/, "audit error_message carries the empty-body cause");
222
+ assert.equal(parseReceipt(run.receipt).status, "failed", "receipt records failed");
223
+ store.close();
224
+ });
225
+
226
+ test("legal empty decision list [] is a no-op success, not a failure", async () => {
227
+ // 真实模型在记忆无冗余时合法输出 [](CONSOLIDATION_PROMPT 允许"无问题无需输出"),
228
+ // 此前被 validateDecisions 判 failed → 审计表反复失败。现在应 ok:true、applied 0、
229
+ // 审计记 ok,且 summary 照常产出。
230
+ const store = createStore(":memory:");
231
+ const service = createService({ store, mirror: null, config: {} });
232
+ service.saveWithDedupe({ type: "project", title: "插件", content: "内容", importance: 3 });
233
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
234
+ const ctx = dreamCtx({ onConsolidation: () => "[]" }); // 模型:无需合并
235
+ const result = await dream.runDream(ctx, service, { dreamMaxTokens: 32768 });
236
+ assert.equal(result.ok, true, "empty [] is a valid no-op, never failed");
237
+ assert.equal(result.applied, 0, "no decisions to apply");
238
+ assert.equal(result.summary, true, "summary still produced");
239
+ const run = store.listDreamRuns()[0];
240
+ assert.equal(run.status, "ok", "audit row records ok, not failed");
241
+ assert.equal(parseReceipt(run.receipt).status, "ok", "receipt records ok");
242
+ store.close();
243
+ });
244
+
245
+ test("issue#25: dreamProvider/dreamModel config wins over the agentDefaultModel route", async () => {
246
+ const store = createStore(":memory:");
247
+ const service = createService({ store, mirror: null, config: {} });
248
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
249
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
250
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
251
+ const captured = [];
252
+ const ctx = dreamCtx({
253
+ captured,
254
+ onConsolidation: () => JSON.stringify([
255
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
256
+ ])
257
+ });
258
+ // dreamCtx's agentDefaultModel resolves (mock:mock-model), but the explicit
259
+ // config route must win — otherwise dreamProvider/dreamModel is dead code in
260
+ // a standard DSH install and the dream can never be moved off a thinking model.
261
+ const result = await dream.runDream(ctx, service, { dreamProvider: "volcano", dreamModel: "deepseek-v3" });
262
+ assert.equal(result.ok, true);
263
+ assert.ok(captured.length >= 2, "consolidation + summary both hit the LLM");
264
+ for (const options of captured) {
265
+ assert.equal(options.provider, "volcano");
266
+ assert.equal(options.model, "deepseek-v3", "config route wins over agentDefaultModel (mock:mock-model)");
267
+ }
268
+ store.close();
269
+ });
270
+
271
+ test("issue#9: rejected reasoningEffort retries once without it and still consolidates", async () => {
272
+ const store = createStore(":memory:");
273
+ const service = createService({ store, mirror: null, config: {} });
274
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
275
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
276
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
277
+ const calls = [];
278
+ const ctx = {
279
+ logger: { warn: () => {} },
280
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
281
+ llm: {
282
+ async *stream(options) {
283
+ calls.push(options);
284
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
285
+ if (userText.startsWith("id=")) {
286
+ // First attempt forwards reasoningEffort: the provider rejects it.
287
+ if (options.reasoningEffort) {
288
+ throw new Error("UNSUPPORTED_REASONING_EFFORT: DeepSeek does not support reasoning effort \"low\"");
289
+ }
290
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
291
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
292
+ ]) };
293
+ } else {
294
+ yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
295
+ }
296
+ yield { type: "finish", reason: { kind: "stop" } };
297
+ }
298
+ }
299
+ };
300
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
301
+ assert.equal(result.ok, true, "run survives the effort rejection via the fallback retry");
302
+ assert.ok(result.applied > 0, "consolidation still lands changes");
303
+ assert.equal(calls.length, 3, "consolidation tried (rejected) + retried without effort + summary");
304
+ assert.equal(calls[0].reasoningEffort, "low", "first consolidation attempt forwards the effort");
305
+ assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
306
+ store.close();
307
+ });
308
+
309
+ // ---------------------------------------------------------------- sleep passthrough
310
+
311
+ function sleepSetup() {
312
+ const store = createStore(":memory:");
313
+ const service = createService({ store, mirror: null, config: {} });
314
+ const vectorIndex = createVectorIndex({ store });
315
+ service.setEmbedder(embedder);
316
+ service.setVectorIndex(vectorIndex);
317
+ return { store, service, vectorIndex };
318
+ }
319
+
320
+ function baseConfig(overrides = {}) {
321
+ return {
322
+ sleepModeEnabled: true,
323
+ sleepIdleMinutes: 5,
324
+ sleepMinIntervalHours: 8,
325
+ sleepConflictStrictness: "normal",
326
+ sleepArchiveDays: 30,
327
+ sleepCompressDays: 90,
328
+ sleepPatternMinMemories: 10,
329
+ sleepMaxPatternPerRun: 3,
330
+ ...overrides
331
+ };
332
+ }
333
+
334
+ /** sleep ctx that records every llm.stream() call's options. */
335
+ function sleepCtx(onConsolidation, selection = { provider: "mock", model: "sleep-model" }, captured = []) {
336
+ return {
337
+ logger: { warn: () => {}, info: () => {} },
338
+ agentDefaultModel: { currentSelection: () => selection },
339
+ llm: {
340
+ async *stream(options) {
341
+ captured.push(options);
342
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
343
+ yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
344
+ yield { type: "finish", reason: { kind: "stop" } };
345
+ }
346
+ }
347
+ };
348
+ }
349
+
350
+ test("issue#9: sleep forwards sleepReasoningEffort on its LLM passes", async () => {
351
+ const { store, service, vectorIndex } = sleepSetup();
352
+ const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
353
+ const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
354
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
355
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
356
+ const captured = [];
357
+ const ctx = sleepCtx(
358
+ (userText) => userText.startsWith("候选冲突")
359
+ ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
360
+ : "[]",
361
+ { provider: "mock", model: "sleep-model" },
362
+ captured
363
+ );
364
+ const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "medium" }), ctx.logger, { embedder, vectorIndex }, null);
365
+ assert.equal(result.status, "ok");
366
+ assert.ok(captured.length >= 2, "conflict + pattern passes both hit the LLM");
367
+ for (const options of captured) {
368
+ assert.equal(options.reasoningEffort, "medium", `reasoningEffort forwarded on ${options.purpose}`);
369
+ }
370
+ store.close();
371
+ });
372
+
373
+ test("issue#135: sleep unset effort also resolves to the lowest supported", async () => {
374
+ const { store, service, vectorIndex } = sleepSetup();
375
+ const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
376
+ const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
377
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
378
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
379
+ const captured = [];
380
+ const ctx = sleepCtx(
381
+ (userText) => userText.startsWith("候选冲突")
382
+ ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
383
+ : "[]",
384
+ { provider: "mock", model: "sleep-model" },
385
+ captured
386
+ );
387
+ ctx.llm.resolveModelInfo = async (provider, model) => ({
388
+ provider,
389
+ model,
390
+ reasoning: { efforts: [{ id: "low" }, { id: "high" }], defaultEffort: "high" }
391
+ });
392
+ const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
393
+ assert.equal(result.status, "ok");
394
+ assert.ok(captured.length >= 2, "conflict + pattern passes both hit the LLM");
395
+ for (const options of captured) {
396
+ assert.equal(options.reasoningEffort, "low", `unset sleep effort resolves to the lowest declared (${options.purpose})`);
397
+ }
398
+ store.close();
399
+ });
400
+
401
+ // ------------------------------------------------------------------ stream-level rejection
402
+ // dsh-llm rc.1 converts adapter-stage failures (including the provider's
403
+ // UNSUPPORTED_REASONING_EFFORT throw from resolveCallWithInfo) into a terminal
404
+ // error finish chunk inside adapterStream — the rejection NEVER reaches our
405
+ // catch. The v0.7.16 throw-based fallback was therefore dead code for the
406
+ // stream path; these tests pin the finish-chunk-based fallback.
407
+
408
+ test("rc.1 stream-level effort rejection (error finish chunk) also triggers the no-effort retry", async () => {
409
+ const store = createStore(":memory:");
410
+ const service = createService({ store, mirror: null, config: {} });
411
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
412
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
413
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
414
+ const calls = [];
415
+ const warnings = [];
416
+ const ctx = {
417
+ logger: { warn: (m) => warnings.push(String(m)) },
418
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
419
+ llm: {
420
+ async *stream(options) {
421
+ calls.push(options);
422
+ if (options.reasoningEffort) {
423
+ yield {
424
+ type: "finish",
425
+ reason: {
426
+ kind: "error",
427
+ failure: {
428
+ code: "UNSUPPORTED_REASONING_EFFORT",
429
+ message: 'provider "mock" model "mock-model" does not support reasoning effort "low"'
430
+ }
431
+ }
432
+ };
433
+ return;
434
+ }
435
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
436
+ if (userText.startsWith("id=")) {
437
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
438
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
439
+ ]) };
440
+ } else {
441
+ yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
442
+ }
443
+ yield { type: "finish", reason: { kind: "stop" } };
444
+ }
445
+ }
446
+ };
447
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
448
+ assert.equal(result.ok, true, "run survives the stream-level effort rejection");
449
+ assert.ok(result.applied > 0, "consolidation still lands changes");
450
+ assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
451
+ assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
452
+ assert.ok(warnings.some((w) => w.includes("rejected via stream")), "the stream-level rejection is logged");
453
+ store.close();
454
+ });
455
+
456
+ test("non-effort stream failures are not retried and the finish-chunk cause reaches the audit row", async () => {
457
+ const store = createStore(":memory:");
458
+ const service = createService({ store, mirror: null, config: {} });
459
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
460
+ const calls = [];
461
+ const ctx = {
462
+ logger: { warn: () => {} },
463
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
464
+ llm: {
465
+ async *stream(options) {
466
+ calls.push(options);
467
+ yield { type: "finish", reason: { kind: "error", failure: { code: "PROVIDER_GONE", message: "provider mock is not registered" } } };
468
+ }
469
+ }
470
+ };
471
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
472
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
473
+ assert.equal(result.ok, false);
474
+ assert.equal(result.error, "llm failed", "the run error stays the stable short string");
475
+ assert.equal(calls.length, 1, "no blind retry when the stream failure is not an effort rejection");
476
+ const row = service.listLlmAudits().find((r) => r.operation_type === "dream_consolidate");
477
+ assert.ok(row && row.status === "error", "failed consolidation still audited");
478
+ assert.ok(
479
+ String(row.error_message).includes("PROVIDER_GONE") && String(row.error_message).includes("provider mock is not registered"),
480
+ "audit error_message carries the finish-chunk cause"
481
+ );
482
+ store.close();
483
+ });
484
+
485
+ test("sleep passes the stream failure accessor so a stream-level effort rejection retries", async () => {
486
+ const { store, service, vectorIndex } = sleepSetup();
487
+ const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
488
+ const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
489
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
490
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
491
+ const captured = [];
492
+ const ctx = sleepCtx(null, { provider: "mock", model: "sleep-model" }, captured);
493
+ ctx.llm.stream = async function* (options) {
494
+ captured.push(options);
495
+ if (options.reasoningEffort) {
496
+ yield {
497
+ type: "finish",
498
+ reason: { kind: "error", failure: { code: "UNSUPPORTED_REASONING_EFFORT", message: 'provider "mock" model "sleep-model" does not support reasoning effort "low"' } }
499
+ };
500
+ return;
501
+ }
502
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
503
+ yield { type: "text-delta", index: 0, text: userText.startsWith("候选冲突")
504
+ ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
505
+ : "[]" };
506
+ yield { type: "finish", reason: { kind: "stop" } };
507
+ };
508
+ const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "low" }), ctx.logger, { embedder, vectorIndex }, null);
509
+ assert.equal(result.status, "ok", "sleep survives the stream-level effort rejection");
510
+ assert.equal(captured[0].reasoningEffort, "low", "first conflict attempt forwards the effort");
511
+ assert.equal("reasoningEffort" in captured[1], false, "conflict retry omits the rejected effort field");
512
+ store.close();
513
+ });
514
+
515
+ // ------------------------------------------------------------------ defaultEffort trap
516
+ // DSH Desktop's volcano-engine adapter declares reasoning.defaultEffort="low"
517
+ // for a model that rejects "low", so omitting the field is NOT a safe retry —
518
+ // the harness substitutes the poison default and fails again. resolveDreamEffort
519
+ // queries resolveModelInfo up front and forwards a value that is actually in
520
+ // the model's declared efforts, so the first attempt already carries a
521
+ // supported effort and never trips UNSUPPORTED_REASONING_EFFORT.
522
+
523
+ test("defaultEffort trap: configured 'low' remapped to the first supported effort when default is poison", async () => {
524
+ const store = createStore(":memory:");
525
+ const service = createService({ store, mirror: null, config: {} });
526
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
527
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
528
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
529
+ const captured = [];
530
+ const infoCalls = [];
531
+ const ctx = dreamCtx({
532
+ captured,
533
+ onConsolidation: () => JSON.stringify([
534
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
535
+ ])
536
+ });
537
+ // The adapter's capability report: defaultEffort "low" is NOT in efforts
538
+ // (the model rejects it) — exactly the volcano-engine/deepseek-v4-flash trap.
539
+ ctx.llm.resolveModelInfo = async (provider, model) => {
540
+ infoCalls.push([provider, model]);
541
+ return {
542
+ provider,
543
+ model,
544
+ reasoning: {
545
+ efforts: [{ id: "medium" }, { id: "high" }],
546
+ defaultEffort: "low"
547
+ }
548
+ };
549
+ };
550
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
551
+ assert.equal(result.ok, true, "run succeeds without ever tripping the poison default");
552
+ assert.ok(result.applied > 0, "consolidation lands changes");
553
+ assert.deepEqual(infoCalls[0], ["mock", "mock-model"], "capability queried for the exact dream route");
554
+ assert.equal(captured[0].reasoningEffort, "medium", "poison 'low' remapped to the first supported effort");
555
+ assert.equal(captured[1].reasoningEffort, "medium", "summary pass uses the same resolved effort");
556
+ store.close();
557
+ });
558
+
559
+ test("defaultEffort trap: model with no reasoning capability omits the field entirely", async () => {
560
+ const store = createStore(":memory:");
561
+ const service = createService({ store, mirror: null, config: {} });
562
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
563
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
564
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
565
+ const captured = [];
566
+ const ctx = dreamCtx({
567
+ captured,
568
+ onConsolidation: () => JSON.stringify([
569
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
570
+ ])
571
+ });
572
+ // Non-thinking model (e.g. deepseek-v4-flash): adapter reports no reasoning
573
+ // capability, so ANY explicit effort would be rejected — the helper must
574
+ // drop it, which is the harness's safe "no reasoning" path.
575
+ ctx.llm.resolveModelInfo = async () => ({ provider: "mock", model: "mock-model", reasoning: undefined });
576
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
577
+ assert.equal(result.ok, true);
578
+ for (const options of captured) {
579
+ assert.equal("reasoningEffort" in options, false, "no reasoning capability -> effort omitted, never rejected");
580
+ }
581
+ store.close();
582
+ });
583
+
584
+ test("defaultEffort trap: configured effort supported is forwarded verbatim", async () => {
585
+ const store = createStore(":memory:");
586
+ const service = createService({ store, mirror: null, config: {} });
587
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
588
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
589
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
590
+ const captured = [];
591
+ const ctx = dreamCtx({
592
+ captured,
593
+ onConsolidation: () => JSON.stringify([
594
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
595
+ ])
596
+ });
597
+ ctx.llm.resolveModelInfo = async () => ({
598
+ provider: "mock",
599
+ model: "mock-model",
600
+ reasoning: { efforts: [{ id: "high" }, { id: "low" }], defaultEffort: "low" }
601
+ });
602
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
603
+ assert.equal(result.ok, true);
604
+ for (const options of captured) {
605
+ assert.equal(options.reasoningEffort, "high", "supported configured value untouched");
606
+ }
607
+ store.close();
608
+ });
609
+
610
+ test("defaultEffort trap: capability query failure falls back to configured effort (retry still guards)", async () => {
611
+ const store = createStore(":memory:");
612
+ const service = createService({ store, mirror: null, config: {} });
613
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
614
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
615
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
616
+ const calls = [];
617
+ const warnings = [];
618
+ const ctx = {
619
+ logger: { warn: (m) => warnings.push(String(m)) },
620
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
621
+ llm: {
622
+ async *stream(options) {
623
+ calls.push(options);
624
+ if (options.reasoningEffort) {
625
+ throw new Error("UNSUPPORTED_REASONING_EFFORT: mock does not support reasoning effort \"high\"");
626
+ }
627
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
628
+ if (userText.startsWith("id=")) {
629
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
630
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
631
+ ]) };
632
+ } else {
633
+ yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
634
+ }
635
+ yield { type: "finish", reason: { kind: "stop" } };
636
+ },
637
+ // Adapter knows nothing about the model — helper must not crash, and the
638
+ // configured effort flows through so withEffortFallback still retries.
639
+ resolveModelInfo: async () => { throw new Error("adapter not reachable"); }
640
+ }
641
+ };
642
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
643
+ assert.equal(result.ok, true, "run succeeds via the no-effort retry");
644
+ assert.equal(calls[0].reasoningEffort, "high", "configured effort forwarded when capability query fails");
645
+ assert.equal("reasoningEffort" in calls[1], false, "rejected effort retried without the field");
646
+ assert.ok(warnings.some((w) => w.includes("resolveModelInfo failed")), "capability-query failure is logged");
647
+ store.close();
648
+ });
649
+
650
+ test("defaultEffort trap: sleep conflict pass remaps a poison effort too", async () => {
651
+ const { store, service, vectorIndex } = sleepSetup();
652
+ const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
653
+ const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
654
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
655
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
656
+ const captured = [];
657
+ const ctx = sleepCtx(
658
+ (userText) => userText.startsWith("候选冲突")
659
+ ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
660
+ : "[]",
661
+ { provider: "mock", model: "sleep-model" },
662
+ captured
663
+ );
664
+ ctx.llm.resolveModelInfo = async (provider, model) => ({
665
+ provider,
666
+ model,
667
+ reasoning: { efforts: [{ id: "medium" }, { id: "high" }], defaultEffort: "low" }
668
+ });
669
+ const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "low" }), ctx.logger, { embedder, vectorIndex }, null);
670
+ assert.equal(result.status, "ok");
671
+ assert.ok(captured.length >= 2, "conflict + pattern passes both hit the LLM");
672
+ for (const options of captured) {
673
+ assert.equal(options.reasoningEffort, "medium", "poison 'low' remapped on sleep passes too");
674
+ }
675
+ store.close();
676
+ });
677
+
678
+ // ------------------------------------------------- entity extraction adapter (issue #108/#109)
679
+ // The streamEntityText adapter lives in index.js; it maps the extractor's
680
+ // options onto a dsh-llm stream route and retries once without the effort
681
+ // when the first attempt is rejected. These tests drive the real exported
682
+ // factory, not a mock of it.
683
+
684
+ test("issue#109: entity extraction effort rejection retries once without the effort", async () => {
685
+ const calls = [];
686
+ const warnings = [];
687
+ const streamEntityText = createEntityStreamAdapter({
688
+ llm: {
689
+ async *stream(options) {
690
+ calls.push(options);
691
+ // First attempt carries the effort: the provider rejects it via an
692
+ // error finish chunk (the realistic stream-level rejection).
693
+ if (options.reasoningEffort) {
694
+ yield { type: "finish", reason: { kind: "error", message: "UNSUPPORTED_REASONING_EFFORT" } };
695
+ return;
696
+ }
697
+ yield { type: "text-delta", index: 0, text: "{\"entities\":[{\"name\":\"张三\",\"type\":\"person\",\"attrs\":[{\"key\":\"职业\",\"value\":\"工程师\",\"confidence\":0.9}]}],\"relations\":[]}" };
698
+ yield { type: "finish", reason: { kind: "stop" } };
699
+ }
700
+ },
701
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
702
+ logger: { warn: (m) => warnings.push(String(m)) }
703
+ });
704
+ const text = await streamEntityText(
705
+ [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
706
+ { reasoningEffort: "low" }
707
+ );
708
+ assert.equal(calls.length, 2, "attempt (rejected) + retry without effort");
709
+ assert.equal(calls[0].reasoningEffort, "low", "first attempt forwards the effort");
710
+ assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
711
+ assert.equal(calls[0].provider, "mock", "route resolved from agentDefaultModel");
712
+ assert.equal(calls[0].model, "mock-model", "route model from agentDefaultModel");
713
+ assert.equal(calls[0].maxTokens, 4096, "extraction caps its output");
714
+ assert.ok(text.includes("张三"), "retry stream text is returned");
715
+ assert.ok(warnings.some((w) => w.includes("rejected, retrying without it")), "rejection is logged");
716
+ });
717
+
718
+ test("issue#109: entity extraction never retries blindly without an effort configured", async () => {
719
+ const calls = [];
720
+ const streamEntityText = createEntityStreamAdapter({
721
+ llm: {
722
+ async *stream(options) {
723
+ calls.push(options);
724
+ yield { type: "finish", reason: { kind: "error", message: "overloaded" } };
725
+ }
726
+ },
727
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
728
+ logger: { warn: () => {} }
729
+ });
730
+ const text = await streamEntityText([{ role: "user", content: [] }], {});
731
+ assert.equal(text, undefined, "failure yields no text");
732
+ assert.equal(calls.length, 1, "no blind retry when no effort was requested");
733
+ });
734
+
735
+ test("issue#109: entity extraction explicit provider/model win over the default route", async () => {
736
+ const calls = [];
737
+ const streamEntityText = createEntityStreamAdapter({
738
+ llm: {
739
+ async *stream(options) {
740
+ calls.push(options);
741
+ yield { type: "text-delta", index: 0, text: "{}" };
742
+ yield { type: "finish", reason: { kind: "stop" } };
743
+ }
744
+ },
745
+ agentDefaultModel: { currentSelection: () => ({ provider: "default", model: "default-model" }) },
746
+ logger: { warn: () => {} }
747
+ });
748
+ await streamEntityText([{ role: "user", content: [] }], { provider: "volcano", model: "deepseek-v3" });
749
+ assert.equal(calls.length, 1);
750
+ assert.equal(calls[0].provider, "volcano", "explicit provider beats the default");
751
+ assert.equal(calls[0].model, "deepseek-v3", "explicit model beats the default");
752
+ });