@modusensus/dsh-mneme 0.8.3 → 0.8.5

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