@modusensus/dsh-mneme 0.8.14 → 0.8.16

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 (279) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +691 -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 +275 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/DISTILL.md +162 -0
  12. package/docs/ENTITIES.md +245 -0
  13. package/docs/LOCAL_MODEL.md +222 -0
  14. package/docs/SEMANTIC.md +256 -0
  15. package/docs/SLEEP.md +164 -0
  16. package/docs/STORAGE.md +98 -0
  17. package/lib/api-standalone.js +656 -673
  18. package/lib/api.js +1605 -1433
  19. package/lib/bootstrap.js +211 -211
  20. package/lib/client.js +6354 -5296
  21. package/lib/commands.js +95 -95
  22. package/lib/config.js +947 -843
  23. package/lib/content-hash.js +54 -54
  24. package/lib/continuity.js +220 -220
  25. package/lib/document-index.js +94 -94
  26. package/lib/document.js +303 -303
  27. package/lib/dream/clustering.js +118 -118
  28. package/lib/dream/decisions.js +694 -674
  29. package/lib/dream/narratives.js +44 -44
  30. package/lib/dream/sleep.js +819 -819
  31. package/lib/dream.js +1503 -1501
  32. package/lib/embedding.js +177 -177
  33. package/lib/entities/extractor.js +252 -252
  34. package/lib/graph/anchoring.js +179 -179
  35. package/lib/guide.js +74 -74
  36. package/lib/heat.js +144 -144
  37. package/lib/hot-memory.js +108 -46
  38. package/lib/index.js +552 -503
  39. package/lib/inject.js +428 -450
  40. package/lib/lang.js +248 -229
  41. package/lib/local-embedder.js +358 -358
  42. package/lib/maintenance.js +162 -142
  43. package/lib/mirror.js +330 -330
  44. package/lib/organize.js +450 -450
  45. package/lib/peak-hours.js +117 -117
  46. package/lib/placement.js +13 -0
  47. package/lib/pre-inject-gate.js +340 -0
  48. package/lib/quality-filter.js +126 -126
  49. package/lib/recall-stats.js +190 -175
  50. package/lib/reclaim-http.js +150 -0
  51. package/lib/reranker.js +269 -269
  52. package/lib/runtime/adopt.js +176 -176
  53. package/lib/runtime/closure.js +172 -172
  54. package/lib/runtime/download.js +247 -247
  55. package/lib/runtime/layout.js +265 -265
  56. package/lib/runtime/loader.js +252 -252
  57. package/lib/runtime/model-fetch.js +361 -361
  58. package/lib/runtime/provision.js +237 -237
  59. package/lib/runtime/tarball.js +130 -130
  60. package/lib/runtime/verify.js +223 -204
  61. package/lib/safe-slice.js +18 -0
  62. package/lib/scope.js +146 -146
  63. package/lib/search/adaptive.js +60 -60
  64. package/lib/search/bm25.js +96 -96
  65. package/lib/semantic.js +229 -229
  66. package/lib/sensitive-scan.js +209 -209
  67. package/lib/serve.js +215 -215
  68. package/lib/service.js +2688 -2605
  69. package/lib/settings.js +528 -511
  70. package/lib/store.js +3196 -3007
  71. package/lib/summarize.js +1039 -856
  72. package/lib/tools.js +743 -737
  73. package/lib/vector-index.js +110 -110
  74. package/lib/version-check.js +122 -122
  75. package/lib/write-admission.js +402 -402
  76. package/locale/en.json +6 -6
  77. package/locale/zh.json +6 -6
  78. package/package.json +118 -118
  79. package/runtime-manifest.json +526 -526
  80. package/scripts/benchmark-embed.js +201 -201
  81. package/scripts/benchmark-recall.js +249 -249
  82. package/scripts/benchmark-rerank.js +166 -166
  83. package/scripts/build-runtime-manifest.mjs +224 -224
  84. package/scripts/changelog-prep.mjs +36 -36
  85. package/scripts/check-sync.js +42 -42
  86. package/scripts/e2e-dsh.js +220 -220
  87. package/scripts/mneme-runtime.mjs +277 -277
  88. package/scripts/stress-dsh.js +255 -255
  89. package/scripts/sync-lib.js +47 -47
  90. package/scripts/sync-test-badge.mjs +69 -69
  91. package/scripts/test-count-sync.mjs +44 -44
  92. package/src/api-standalone.js +656 -673
  93. package/src/api.js +1605 -1433
  94. package/src/bootstrap.js +211 -211
  95. package/src/commands.js +95 -95
  96. package/src/config.js +947 -843
  97. package/src/content-hash.js +54 -54
  98. package/src/continuity.js +220 -220
  99. package/src/document-index.js +94 -94
  100. package/src/document.js +303 -303
  101. package/src/dream/clustering.js +118 -118
  102. package/src/dream/decisions.js +694 -674
  103. package/src/dream/narratives.js +44 -44
  104. package/src/dream/sleep.js +819 -819
  105. package/src/dream.js +1503 -1501
  106. package/src/embedding.js +177 -177
  107. package/src/entities/extractor.js +252 -252
  108. package/src/graph/anchoring.js +179 -179
  109. package/src/guide.js +74 -74
  110. package/src/heat.js +144 -144
  111. package/src/hot-memory.js +108 -46
  112. package/src/index.js +552 -503
  113. package/src/inject.js +428 -450
  114. package/src/lang.js +248 -229
  115. package/src/local-embedder.js +358 -358
  116. package/src/maintenance.js +162 -142
  117. package/src/mirror.js +330 -330
  118. package/src/organize.js +450 -450
  119. package/src/peak-hours.js +117 -117
  120. package/src/placement.js +13 -0
  121. package/src/pre-inject-gate.js +340 -0
  122. package/src/quality-filter.js +126 -126
  123. package/src/recall-stats.js +190 -175
  124. package/src/reclaim-http.js +150 -0
  125. package/src/reranker.js +269 -269
  126. package/src/runtime/adopt.js +176 -176
  127. package/src/runtime/closure.js +172 -172
  128. package/src/runtime/download.js +247 -247
  129. package/src/runtime/layout.js +265 -265
  130. package/src/runtime/loader.js +252 -252
  131. package/src/runtime/model-fetch.js +361 -361
  132. package/src/runtime/provision.js +237 -237
  133. package/src/runtime/tarball.js +130 -130
  134. package/src/runtime/verify.js +223 -204
  135. package/src/safe-slice.js +18 -0
  136. package/src/scope.js +146 -146
  137. package/src/search/adaptive.js +60 -60
  138. package/src/search/bm25.js +96 -96
  139. package/src/semantic.js +229 -229
  140. package/src/sensitive-scan.js +209 -209
  141. package/src/serve.js +215 -215
  142. package/src/service.js +2688 -2605
  143. package/src/settings.js +528 -511
  144. package/src/store.js +3196 -3007
  145. package/src/summarize.js +1039 -856
  146. package/src/tools.js +743 -737
  147. package/src/vector-index.js +110 -110
  148. package/src/version-check.js +122 -122
  149. package/src/write-admission.js +402 -402
  150. package/test/adaptive-inject-budget.test.js +85 -85
  151. package/test/api-routes-gaps.test.js +899 -218
  152. package/test/api.test.js +1725 -1690
  153. package/test/audit.test.js +448 -448
  154. package/test/baseline-persist.test.js +248 -248
  155. package/test/benchmark.test.js +94 -94
  156. package/test/bootstrap.test.js +220 -220
  157. package/test/changelog-prep.test.js +60 -60
  158. package/test/client.test.js +1582 -1169
  159. package/test/clustering.test.js +100 -100
  160. package/test/commands.test.js +106 -106
  161. package/test/config.test.js +66 -66
  162. package/test/conflict-freeze.test.js +300 -300
  163. package/test/content-hash.test.js +204 -204
  164. package/test/continuity.test.js +333 -333
  165. package/test/contrast.test.js +314 -0
  166. package/test/document-dir.test.js +233 -233
  167. package/test/document.test.js +671 -671
  168. package/test/dream-archive-guardrail.test.js +116 -116
  169. package/test/dream-candidate-hybrid.test.js +281 -281
  170. package/test/dream-coverage-degrade.test.js +86 -86
  171. package/test/dream-failure-backoff.test.js +173 -173
  172. package/test/dream-merge-guardrail.test.js +219 -0
  173. package/test/dream-peak-hours.test.js +194 -194
  174. package/test/dream.test.js +1385 -1383
  175. package/test/embedding-ready.test.js +196 -196
  176. package/test/entities.test.js +627 -547
  177. package/test/entity-recall.test.js +179 -179
  178. package/test/epistemic.test.js +325 -325
  179. package/test/fnew-0112.test.js +311 -311
  180. package/test/fnew-03.test.js +422 -422
  181. package/test/graph-anchoring.test.js +184 -184
  182. package/test/graph-api.test.js +175 -175
  183. package/test/graph-inject-budget.test.js +103 -103
  184. package/test/graph-passive-confirm.test.js +91 -91
  185. package/test/graph-weight-evolution.test.js +104 -104
  186. package/test/heat.test.js +175 -175
  187. package/test/helpers/dream-mock.js +82 -82
  188. package/test/helpers/peer-worker.mjs +25 -25
  189. package/test/helpers/runtime-source.js +60 -60
  190. package/test/helpers/tar-builder.js +80 -80
  191. package/test/helpers/write-admission-samples.js +308 -308
  192. package/test/hot-memory.test.js +211 -145
  193. package/test/inject-content-cap.test.js +91 -91
  194. package/test/inject-first-round-bm25.test.js +89 -89
  195. package/test/inject-heat.test.js +77 -77
  196. package/test/inject-parent-gate.test.js +106 -106
  197. package/test/inject-pools.test.js +194 -194
  198. package/test/inject-recall.test.js +144 -105
  199. package/test/inject-rotation.test.js +161 -161
  200. package/test/inject.test.js +443 -420
  201. package/test/lang.test.js +87 -87
  202. package/test/lastrunat-persist.test.js +101 -101
  203. package/test/lib-smoke.test.js +96 -96
  204. package/test/llm-audit.test.js +696 -598
  205. package/test/llm-message-source.test.js +207 -207
  206. package/test/local-embedder.test.js +292 -292
  207. package/test/maintenance.test.js +501 -391
  208. package/test/mcp-stdio.test.js +385 -385
  209. package/test/mirror-dirty.test.js +424 -424
  210. package/test/mirror-edit-digest.test.js +187 -187
  211. package/test/mirror-generation.test.js +499 -499
  212. package/test/mirror-types.test.js +208 -208
  213. package/test/mirror.test.js +249 -249
  214. package/test/narrative-bars.test.js +162 -162
  215. package/test/organize.test.js +418 -418
  216. package/test/peer-blockers.test.js +193 -193
  217. package/test/placement.test.js +36 -0
  218. package/test/policy-epoch.test.js +259 -259
  219. package/test/pre-inject-gate.test.js +444 -0
  220. package/test/quality-filter.test.js +186 -186
  221. package/test/reasoning-effort.test.js +752 -752
  222. package/test/recall-anti-update.test.js +82 -82
  223. package/test/recall-evals.test.js +253 -253
  224. package/test/recall-layer.test.js +326 -326
  225. package/test/recall-stats.test.js +274 -189
  226. package/test/receipt-chain.test.js +451 -451
  227. package/test/reclaim-http.test.js +209 -0
  228. package/test/reflection.test.js +226 -226
  229. package/test/reindex-backfill.test.js +62 -62
  230. package/test/reranker-single-logit.test.js +73 -73
  231. package/test/reranker.test.js +197 -197
  232. package/test/resident-status.test.js +83 -83
  233. package/test/runtime-adopt.test.js +206 -206
  234. package/test/runtime-cli.test.js +89 -89
  235. package/test/runtime-closure.test.js +193 -193
  236. package/test/runtime-download.test.js +302 -302
  237. package/test/runtime-layout.test.js +163 -163
  238. package/test/runtime-loader.test.js +255 -255
  239. package/test/runtime-manifest.test.js +159 -159
  240. package/test/runtime-model-fetch.test.js +348 -348
  241. package/test/runtime-provision.test.js +101 -101
  242. package/test/runtime-tarball.test.js +81 -81
  243. package/test/runtime-verify.test.js +319 -279
  244. package/test/scope-candidates.test.js +239 -239
  245. package/test/scope-explicit.test.js +452 -452
  246. package/test/scope-inject-soft.test.js +106 -106
  247. package/test/scope-retrieval.test.js +197 -197
  248. package/test/scope-storage.test.js +255 -255
  249. package/test/scope-strict.test.js +367 -367
  250. package/test/search-fusion.test.js +90 -90
  251. package/test/semantic.test.js +124 -124
  252. package/test/sensitive-scan.test.js +207 -207
  253. package/test/serve-bin.test.js +121 -121
  254. package/test/serve.test.js +196 -196
  255. package/test/service-search.test.js +174 -174
  256. package/test/service.test.js +410 -405
  257. package/test/settings.test.js +269 -261
  258. package/test/skip-invalid-restore.test.js +262 -262
  259. package/test/sleep-action-set.test.js +422 -422
  260. package/test/sleep-heat.test.js +126 -126
  261. package/test/sleep-scheduler-rearm.test.js +89 -89
  262. package/test/sleep.test.js +527 -527
  263. package/test/standalone-api.test.js +751 -751
  264. package/test/store-vector-cache.test.js +98 -98
  265. package/test/store.test.js +504 -396
  266. package/test/stress.test.js +209 -209
  267. package/test/summarize-occurred-at.test.js +127 -0
  268. package/test/summarize-reasoning-effort.test.js +189 -189
  269. package/test/summarize.test.js +1639 -1465
  270. package/test/supersede-state-tracking.test.js +394 -0
  271. package/test/test-count-sync.test.js +60 -60
  272. package/test/tools-dto-schema-matrix.test.js +325 -325
  273. package/test/tools-memory-get-schema.test.js +65 -65
  274. package/test/tools.test.js +428 -374
  275. package/test/updated-at-semantics.test.js +113 -113
  276. package/test/vector-index.test.js +221 -221
  277. package/test/version-check.test.js +152 -152
  278. package/test/webServer-optional.test.js +82 -82
  279. 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
+ });