@modusensus/dsh-mneme 0.8.14 → 0.8.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -654
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -151
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -673
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -843
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -503
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -229
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -215
  65. package/lib/service.js +2663 -2605
  66. package/lib/settings.js +517 -511
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +118 -118
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -36
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -673
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -843
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -503
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -229
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -215
  137. package/src/service.js +2663 -2605
  138. package/src/settings.js +517 -511
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1690
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -60
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -77
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -367
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -121
  246. package/test/serve.test.js +196 -196
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -751
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
@@ -1,1465 +1,1465 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtempSync, rmSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { tmpdir } from "node:os";
6
- import { createStore } from "../src/store.js";
7
- import { createService } from "../src/service.js";
8
- import { createSummarizer, parseSummaryJson, parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "../src/summarize.js";
9
- import { createSettings } from "../src/settings.js";
10
-
11
- // Issue #239 第 4 项:可注入的假时钟 + 定时器——错峰队列的测试必须能「推进时间」,
12
- // 真实 setTimeout 会让补跑路径要么测不到、要么拖慢测试。
13
- function fakeClock(start) {
14
- let current = start.getTime();
15
- const timers = [];
16
- const deps = {
17
- now: () => current,
18
- setTimeoutFn: (fn, ms) => {
19
- const entry = { fn, at: current + ms };
20
- timers.push(entry);
21
- return entry;
22
- },
23
- clearTimeoutFn: (entry) => {
24
- const i = timers.indexOf(entry);
25
- if (i !== -1) timers.splice(i, 1);
26
- }
27
- };
28
- const flush = async () => {
29
- // 补跑是异步链(enqueueDistill → stream):推完时间要给微任务与一轮宏任务机会。
30
- for (let i = 0; i < 4; i++) await new Promise((resolve) => setTimeout(resolve, 0));
31
- };
32
- return {
33
- deps,
34
- pending: () => timers.length,
35
- async advanceTo(date) {
36
- current = date.getTime();
37
- for (const entry of [...timers].sort((a, b) => a.at - b.at)) {
38
- if (entry.at > current) continue;
39
- timers.splice(timers.indexOf(entry), 1);
40
- entry.fn();
41
- }
42
- await flush();
43
- },
44
- async advance(ms) {
45
- await this.advanceTo(new Date(current + ms));
46
- }
47
- };
48
- }
49
-
50
- function setup(over = {}, opts = {}) {
51
- const store = opts.store ?? createStore(":memory:");
52
- const service = createService({ store, mirror: null, config: {} });
53
- const events = [];
54
- const calls = [];
55
- const ctx = {
56
- on(name, fn) {
57
- events.push({ name, fn });
58
- return () => {
59
- const i = events.findIndex((e) => e.name === name && e.fn === fn);
60
- if (i !== -1) events.splice(i, 1);
61
- };
62
- },
63
- llm: {
64
- stream(options) {
65
- calls.push(options);
66
- if (opts.stream) return opts.stream(options);
67
- const json = JSON.stringify([
68
- { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
69
- { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
70
- ]);
71
- return (async function* () {
72
- yield { type: "block-start", block: { type: "text" } };
73
- yield { type: "text-delta", delta: json };
74
- yield { type: "block-end", block: { type: "text" } };
75
- yield { type: "finish", kind: "ok" };
76
- })();
77
- }
78
- }
79
- };
80
- const config = { autoSummarize: true, ...over };
81
- const summarizer = createSummarizer(ctx, service, config, opts.deps ?? {});
82
- return { store, service, events, calls, summarizer };
83
- }
84
-
85
- // A realistic direct human prompt event (source.kind === "user").
86
- function userMessage(text, seq) {
87
- const event = {
88
- type: "user/message",
89
- data: { source: { kind: "user" }, content: [{ type: "text", text }] }
90
- };
91
- if (seq !== undefined) event.seq = seq;
92
- return event;
93
- }
94
-
95
- function toolResultEvent(text, isError, seq) {
96
- return {
97
- seq,
98
- type: "tool/result",
99
- data: {
100
- message: {
101
- source: { kind: "tool", callId: `call-${seq}` },
102
- content: [{
103
- type: "tool-result",
104
- toolCallId: `call-${seq}`,
105
- isError,
106
- content: [{ type: "text", text }]
107
- }]
108
- }
109
- }
110
- };
111
- }
112
-
113
- // DSH 0.2.x 内核对工具结果的实际投递形状:内核给的是 `role:"tool"` 消息本身,
114
- // content 里只有 `type:"text"` 块、isError 挂在消息上,不再有 tool-result 块。
115
- // 蒸馏曾只认旧块形状,于是真实会话里的工具输出与工具报错全部被静默丢弃。
116
- function toolRoleResultEvent(text, isError, seq) {
117
- return {
118
- seq,
119
- type: "tool/result",
120
- data: {
121
- message: {
122
- role: "tool",
123
- source: { kind: "tool", callId: `call-${seq}` },
124
- toolCallId: `call-${seq}`,
125
- content: [{ type: "text", text }],
126
- isError
127
- }
128
- }
129
- };
130
- }
131
-
132
- function codeDispatchEvent(text, isError, seq) {
133
- return {
134
- seq,
135
- type: "tool/code-dispatch",
136
- data: {
137
- isError,
138
- content: [{ type: "text", text }]
139
- }
140
- };
141
- }
142
-
143
- function inboxMessage(id, kind, text) {
144
- return {
145
- id,
146
- role: "user",
147
- source: { kind },
148
- content: [{ type: "text", text }]
149
- };
150
- }
151
-
152
- test("parseSummaryJson extracts valid entries and skips malformed ones", () => {
153
- const parsed = parseSummaryJson(`前导文字 {"a":1}
154
- [
155
- {"type":"decision","title":"t1","content":"c1","importance":4},
156
- {"type":"nonsense","title":"bad","content":"x"},
157
- "garbage",
158
- {"type":"preference","title":"t2","content":"c2","importance":2}
159
- ]`);
160
- assert.equal(parsed.length, 2);
161
- assert.equal(parsed[0].type, "decision");
162
- assert.equal(parsed[1].type, "preference");
163
- });
164
-
165
- // --- issue #339 / E8 考卷:JSON 崩溃窗口的 salvage(失败路径修复,默认开)-----
166
- // E8 实测 10-20% 的窗口输出了含约束的完整对象,只因中段一处语法错误被整窗
167
- // JSON.parse 拒收 → 生产行为 = 游标卡死 + 温度 0 重试同错 = 静默丢失。
168
-
169
- test("parseSummaryJson salvages complete objects from a malformed array (issue #339)", () => {
170
- // E8 现场同款:闭合数组、中段对象含杂散引号(stray quote)。
171
- const raw = `[
172
- {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
173
- { " "type": "history", "title": "坏对象", "content": "语法错误" },
174
- {"type":"preference","title":"硬件","content":"只要 brushed brass"}
175
- ]`;
176
- const parsed = parseSummaryJson(raw);
177
- assert.equal(parsed.length, 2, "the broken middle object is dropped, both survivors kept");
178
- assert.equal(parsed[0].content, "总额 $12,400");
179
- assert.equal(parsed[1].title, "硬件");
180
- });
181
-
182
- test("parseSummaryJson salvage finding nothing stays a failure (no fake empty success)", () => {
183
- // 只有一个语法坏掉的对象 → 救回 0 条:不得按「显式空数组」放行消费窗口。
184
- const raw = `[
185
- { " "type": "history", "title": "坏", "content": "语法错误" }
186
- ]`;
187
- const parsed = parseSummaryJson(raw);
188
- assert.deepEqual(parsed, []);
189
- });
190
-
191
- test("subscribes to session/event when autoSummarize enabled", () => {
192
- const { events } = setup();
193
- assert.ok(events.some((e) => e.name === "session/event"));
194
- });
195
-
196
- test("does not subscribe when autoSummarize disabled", () => {
197
- const { events } = setup({ autoSummarize: false });
198
- assert.ok(!events.some((e) => e.name === "session/event"));
199
- });
200
-
201
- test("turn/end event triggers summarization and stores entries", async () => {
202
- const { events, store } = setup();
203
- const handler = events.find((e) => e.name === "session/event").fn;
204
- const session = {
205
- id: "s1",
206
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
207
- events: [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
208
- };
209
- await handler(session, { seq: 2, type: "turn/end" });
210
- assert.equal(store.count(), 2);
211
- const all = store.all();
212
- assert.ok(all.some((m) => m.type === "decision"));
213
- assert.ok(all.some((m) => m.type === "preference"));
214
- });
215
-
216
- test("skips summarization for events other than turn/end", async () => {
217
- const { events, store, calls } = setup();
218
- const handler = events.find((e) => e.name === "session/event").fn;
219
- const session = { id: "s1", requestHeader: () => ({ config: {} }), events: [] };
220
- await handler(session, { seq: 1, type: "user/message" });
221
- assert.equal(store.count(), 0);
222
- assert.equal(calls.length, 0);
223
- });
224
-
225
- // DSH ≥0.1.2-rc removed Session.events; the session object only exposes
226
- // snapshotEvents(). Regression for #59: with only the old .events path the
227
- // collector saw an empty log and no LLM call ever happened.
228
- test("reads events from snapshotEvents() when Session.events is absent", async () => {
229
- const { events, store, calls } = setup();
230
- const handler = events.find((e) => e.name === "session/event").fn;
231
- const session = {
232
- id: "s1",
233
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
234
- snapshotEvents: () => [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
235
- };
236
- await handler(session, { seq: 2, type: "turn/end" });
237
- assert.equal(calls.length, 1, "an LLM call must be made");
238
- assert.equal(store.count(), 2);
239
- });
240
-
241
- // --- Issue #239:成本感知级联(零 LLM 预判)与有界检查点 ---------------------
242
- // 验收对齐 #239:日志可见 skip 原因、默认配置下行为零变化、开启后调用量下降。
243
-
244
- test("#239 预判默认关:阈值为 0 时不改变现状,照常发起调用", async () => {
245
- const { events, calls, store } = setup();
246
- const handler = events.find((e) => e.name === "session/event").fn;
247
- const session = {
248
- id: "s239-default",
249
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
250
- events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
251
- };
252
- await handler(session, { seq: 2, type: "turn/end" });
253
- assert.equal(calls.length, 1, "默认不预判:再短的窗口也照常蒸馏");
254
- assert.equal(store.count(), 2);
255
- });
256
-
257
- test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费游标", async () => {
258
- const { events, calls, service, store } = setup({ summarizeMinWindowChars: 1000 });
259
- const handler = events.find((e) => e.name === "session/event").fn;
260
- const session = {
261
- id: "s239-small",
262
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
263
- events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
264
- };
265
- await handler(session, { seq: 2, type: "turn/end" });
266
- assert.equal(calls.length, 0, "短窗口不得发起 LLM 调用");
267
- assert.equal(store.count(), 0, "被拦下就不该写入记忆");
268
- const rows = service.listLlmAudits();
269
- assert.equal(rows[0].status, "skipped");
270
- assert.equal(rows[0].error_message, "window-too-small", "skip 原因必须可观测");
271
-
272
- await handler(session, { seq: 2, type: "turn/end" });
273
- assert.equal(calls.length, 0);
274
- assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计");
275
- });
276
-
277
- test("#239 窗口达到阈值时预判放行(不误杀)", async () => {
278
- const { events, calls } = setup({ summarizeMinWindowChars: 5 });
279
- const handler = events.find((e) => e.name === "session/event").fn;
280
- const session = {
281
- id: "s239-pass",
282
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
283
- events: [userMessage("这是一段足够长的用户输入", 1), { seq: 2, type: "turn/end" }]
284
- };
285
- await handler(session, { seq: 2, type: "turn/end" });
286
- assert.equal(calls.length, 1);
287
- });
288
-
289
- test("#239 每会话 run 预算:额度用尽后不再调用,审计写明原因", async () => {
290
- const { events, calls, service } = setup({ summarizeMaxRunsPerSession: 1, distillRateLimitIntervalMs: 0 });
291
- const handler = events.find((e) => e.name === "session/event").fn;
292
- const session = {
293
- id: "s239-cap",
294
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
295
- events: [userMessage("第一轮", 1), { seq: 2, type: "turn/end" }]
296
- };
297
- await handler(session, { seq: 2, type: "turn/end" });
298
- assert.equal(calls.length, 1, "第一次在预算内");
299
-
300
- session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
301
- await handler(session, { seq: 4, type: "turn/end" });
302
- assert.equal(calls.length, 1, "预算用尽后不得再调用");
303
- const rows = service.listLlmAudits();
304
- assert.equal(rows[0].status, "skipped");
305
- assert.equal(rows[0].error_message, "max-runs-per-session");
306
- });
307
-
308
- test("#239 预算只计真实调用:被预判拦下的窗口不消耗额度", async () => {
309
- const { events, calls } = setup({ summarizeMaxRunsPerSession: 1, summarizeMinWindowChars: 10, distillRateLimitIntervalMs: 0 });
310
- const handler = events.find((e) => e.name === "session/event").fn;
311
- const session = {
312
- id: "s239-budget",
313
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
314
- events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
315
- };
316
- await handler(session, { seq: 2, type: "turn/end" });
317
- assert.equal(calls.length, 0, "第一轮被零 LLM 预判拦下");
318
-
319
- session.events.push(userMessage("第二轮写得足够长,应该放行并消耗唯一的一次额度", 3), { seq: 4, type: "turn/end" });
320
- await handler(session, { seq: 4, type: "turn/end" });
321
- assert.equal(calls.length, 1, "零成本窗口不该吃掉预算");
322
- });
323
-
324
- // --- Issue #239 第 4 项:错峰队列 -------------------------------------------------
325
-
326
- test("#239-4 高峰时段解析:合法 / 多段 / 跨零点 / 星期前缀 / 非法一律按未配置", () => {
327
- assert.equal(parsePeakSpec(""), null);
328
- assert.equal(parsePeakSpec(" "), null);
329
- assert.deepEqual(parsePeakSpec("09:00-18:00"), { days: null, windows: [{ start: 540, end: 1080 }] });
330
- assert.deepEqual(parsePeakSpec("09:00-12:00, 14:00-18:00"), {
331
- days: null,
332
- windows: [{ start: 540, end: 720 }, { start: 840, end: 1080 }]
333
- });
334
- assert.deepEqual(parsePeakSpec("23:00-06:00"), { days: null, windows: [{ start: 1380, end: 360 }] });
335
- // 星期前缀:ISO 1=周一…7=周日,也认 mon..sun;支持列表与跨周环绕
336
- assert.deepEqual(parsePeakSpec("mon-fri 08:00-12:00,14:00-18:00"), {
337
- days: [1, 2, 3, 4, 5],
338
- windows: [{ start: 480, end: 720 }, { start: 840, end: 1080 }]
339
- });
340
- assert.deepEqual(parsePeakSpec("1-5 08:00-12:00").days, [1, 2, 3, 4, 5]);
341
- assert.deepEqual(parsePeakSpec("sat,sun 10:00-12:00").days, [6, 7]);
342
- assert.deepEqual(parsePeakSpec("fri-mon 10:00-12:00").days, [1, 5, 6, 7], "跨周环绕");
343
- // 排程是省钱手段,绝不该因为写错格式把蒸馏停掉:任何非法写法都当作「关闭」。
344
- for (const bad of ["09:00~18:00", "24:00-06:00", "09:60-18:00", "09:00-09:00", "abc", "09:00-18:00,oops", "weekday 09:00-18:00", "8 09:00-18:00"]) {
345
- assert.equal(parsePeakSpec(bad), null, `${bad} 应视为未配置`);
346
- }
347
- });
348
-
349
- test("#239-4 高峰判定与补跑时刻(跨零点、右开区间)", () => {
350
- const at = (h, m) => new Date(2026, 8, 19, h, m, 30);
351
- assert.equal(isInPeakWindow(at(10, 0), "09:00-18:00"), true);
352
- assert.equal(isInPeakWindow(at(18, 0), "09:00-18:00"), false, "右开区间:18:00 已出高峰");
353
- assert.equal(isInPeakWindow(at(3, 0), "23:00-06:00"), true, "跨零点时段");
354
- assert.equal(isInPeakWindow(at(12, 0), "23:00-06:00"), false);
355
- assert.equal(isInPeakWindow(at(10, 0), ""), false, "未配置 = 永不高峰");
356
- const next = nextOffPeakAt(at(10, 30), "09:00-18:00");
357
- assert.equal(next.getHours(), 18);
358
- assert.equal(next.getMinutes(), 0);
359
- assert.equal(nextOffPeakAt(at(3, 0), "23:00-06:00").getHours(), 6, "跨零点取次日 06:00 结束点");
360
- assert.equal(nextOffPeakAt(at(20, 0), "09:00-18:00"), null, "非高峰时刻无需补跑");
361
- });
362
-
363
- // 夹具:按 ISO 星期几(1=周一…7=周日)从当月 1 号推算出日期,避免把星期写死在测试里。
364
- function dateOnIsoDay(isoDay, hour, minute) {
365
- const first = new Date(2026, 8, 1, hour, minute, 0);
366
- const firstIso = first.getDay() === 0 ? 7 : first.getDay();
367
- return new Date(2026, 8, 1 + ((isoDay - firstIso + 7) % 7), hour, minute, 0);
368
- }
369
-
370
- test("#239-4 星期过滤:工作日高峰不误伤周末(跨零点段按「开窗那天」认星期)", () => {
371
- const spec = "mon-fri 08:00-12:00,14:00-18:00";
372
- assert.equal(dateOnIsoDay(1, 9, 0).getDay(), 1, "夹具自检:周一");
373
- assert.equal(dateOnIsoDay(6, 9, 0).getDay(), 6, "夹具自检:周六");
374
- assert.equal(isInPeakWindow(dateOnIsoDay(1, 9, 0), spec), true, "工作日 09:00 在高峰");
375
- assert.equal(isInPeakWindow(dateOnIsoDay(1, 12, 30), spec), false, "工作日午休不在高峰");
376
- assert.equal(isInPeakWindow(dateOnIsoDay(6, 9, 0), spec), false, "周六同刻不在高峰(按周计费)");
377
- assert.equal(isInPeakWindow(dateOnIsoDay(7, 15, 0), spec), false, "周日下午不在高峰");
378
- assert.equal(nextOffPeakAt(dateOnIsoDay(1, 9, 0), spec).getHours(), 12, "工作日顺延到 12:00");
379
- assert.equal(nextOffPeakAt(dateOnIsoDay(6, 9, 0), spec), null, "周末无需顺延");
380
- // 跨零点段:周五 23:00 开的窗口延续到周六凌晨,仍算高峰;周六开的则不覆盖周日
381
- const overnight = "mon-fri 23:00-06:00";
382
- assert.equal(isInPeakWindow(dateOnIsoDay(6, 2, 0), overnight), true, "周六 02:00 属周五开的窗口");
383
- assert.equal(isInPeakWindow(dateOnIsoDay(7, 2, 0), overnight), false, "周日 02:00 属周六开的窗口(周六不在集合内)");
384
- });
385
-
386
- test("#239-4 默认关:未配置高峰时行为与现状一致", async () => {
387
- const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
388
- const { events, calls } = setup({}, { deps: clock.deps });
389
- const handler = events.find((e) => e.name === "session/event").fn;
390
- const session = {
391
- id: "s239-peak-off",
392
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
393
- events: [userMessage("普通一轮", 1), { seq: 2, type: "turn/end" }]
394
- };
395
- await handler(session, { seq: 2, type: "turn/end" });
396
- assert.equal(calls.length, 1, "未配置高峰 → 照常蒸馏");
397
- assert.equal(clock.pending(), 0, "不该挂补跑定时器");
398
- });
399
-
400
- test("#239-4 高峰内不调模型:skip 审计 + 不消费游标,非高峰补跑整窗", async () => {
401
- const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
402
- const { events, calls, service, store } = setup(
403
- { summarizePeakHours: "09:00-18:00", distillRateLimitIntervalMs: 0 },
404
- { deps: clock.deps }
405
- );
406
- const handler = events.find((e) => e.name === "session/event").fn;
407
- const session = {
408
- id: "s239-peak",
409
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
410
- events: [userMessage("高峰里的第一段", 1), { seq: 2, type: "turn/end" }]
411
- };
412
- await handler(session, { seq: 2, type: "turn/end" });
413
- assert.equal(calls.length, 0, "高峰期不得调模型");
414
- assert.equal(store.count(), 0, "不该写入记忆");
415
- const rows = service.listLlmAudits();
416
- assert.equal(rows[0].status, "skipped");
417
- assert.equal(rows[0].error_message, "peak-hours", "skip 原因必须可观测");
418
- assert.equal(clock.pending(), 1, "应挂一个补跑定时器");
419
-
420
- // 高峰期间又来一轮:游标未消费 → 窗口累积;仍不调模型、定时器不叠加
421
- session.events.push(userMessage("高峰里的第二段", 3), { seq: 4, type: "turn/end" });
422
- await handler(session, { seq: 4, type: "turn/end" });
423
- assert.equal(calls.length, 0);
424
- assert.equal(clock.pending(), 1, "每会话只挂一个定时器");
425
-
426
- // 推到非高峰:一次补跑,且蒸到的是累积后的整窗
427
- await clock.advanceTo(new Date(2026, 8, 19, 18, 0, 5));
428
- assert.equal(calls.length, 1, "非高峰补跑一次");
429
- const prompt = JSON.stringify(calls[0].messages);
430
- assert.ok(prompt.includes("高峰里的第一段") && prompt.includes("高峰里的第二段"),
431
- "补跑应蒸馏累积后的完整窗口");
432
- assert.ok(store.count() > 0, "补跑真的写入了记忆");
433
- });
434
-
435
- test("#239-4 顺延上限:长高峰到点照跑,不饿死蒸馏", async () => {
436
- const clock = fakeClock(new Date(2026, 8, 19, 9, 0, 0));
437
- const { events, calls } = setup(
438
- { summarizePeakHours: "09:00-18:00", summarizePeakMaxDeferMinutes: 30, distillRateLimitIntervalMs: 0 },
439
- { deps: clock.deps }
440
- );
441
- const handler = events.find((e) => e.name === "session/event").fn;
442
- const session = {
443
- id: "s239-peak-cap",
444
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
445
- events: [userMessage("长高峰里的一轮", 1), { seq: 2, type: "turn/end" }]
446
- };
447
- await handler(session, { seq: 2, type: "turn/end" });
448
- assert.equal(calls.length, 0, "高峰内先顺延");
449
- await clock.advance(31 * 60000);
450
- assert.equal(calls.length, 1, "上限到点仍处高峰也照跑(bypassPeak)");
451
- });
452
-
453
- test("does not call the LLM when no event was added after the last successful seq", async () => {
454
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
455
- const handler = events.find((e) => e.name === "session/event").fn;
456
- const transcript = [userMessage("首轮", 1), { seq: 2, type: "turn/end" }];
457
- const ranges = [];
458
- const session = {
459
- id: "s-noop",
460
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
461
- snapshotEvents(fromSeq, toSeq) {
462
- ranges.push([fromSeq, toSeq]);
463
- return transcript;
464
- }
465
- };
466
-
467
- await handler(session, { seq: 2, type: "turn/end" });
468
- await handler(session, { seq: 2, type: "turn/end" });
469
-
470
- assert.equal(calls.length, 1, "a successful window must not be distilled twice");
471
- assert.equal(ranges[1][0], 2, "the successful event seq is passed as the next snapshot lower bound");
472
- });
473
-
474
- test("resumes a persisted seq cursor after the summarizer is restarted", async () => {
475
- const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-distill-cursor-"));
476
- const dbPath = join(dir, "memory.db");
477
- let firstStore;
478
- let firstSummarizer;
479
- let secondStore;
480
- let secondSummarizer;
481
- try {
482
- firstStore = createStore(dbPath);
483
- const first = setup({ distillRateLimitIntervalMs: 0 }, { store: firstStore });
484
- firstSummarizer = first.summarizer;
485
- const session = {
486
- id: "s-restart",
487
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
488
- events: [userMessage("重启前的窗口", 1), { seq: 2, type: "turn/end" }]
489
- };
490
- const firstHandler = first.events.find((e) => e.name === "session/event").fn;
491
- await firstHandler(session, { seq: 2, type: "turn/end" });
492
- assert.equal(first.calls.length, 1);
493
- assert.equal(firstStore.getDistillCursor("s-restart").last_seq, 2);
494
-
495
- firstSummarizer.dispose();
496
- firstStore.close();
497
- firstSummarizer = undefined;
498
- firstStore = undefined;
499
-
500
- secondStore = createStore(dbPath);
501
- const second = setup({ distillRateLimitIntervalMs: 0 }, { store: secondStore });
502
- secondSummarizer = second.summarizer;
503
- const secondHandler = second.events.find((e) => e.name === "session/event").fn;
504
- await secondHandler(session, { seq: 2, type: "turn/end" });
505
-
506
- assert.equal(second.calls.length, 0, "a restarted summarizer must reuse the persisted cursor");
507
- assert.equal(secondStore.count(), 2, "the restart must not duplicate memories");
508
- assert.equal(secondStore.getDistillCursor("s-restart").last_seq, 2);
509
- } finally {
510
- secondSummarizer?.dispose();
511
- secondStore?.close();
512
- firstSummarizer?.dispose();
513
- firstStore?.close();
514
- rmSync(dir, { recursive: true, force: true });
515
- }
516
- });
517
-
518
- test("treats a valid empty summary as a successful cursor commit", async () => {
519
- const { events, store, calls } = setup(
520
- { distillRateLimitIntervalMs: 0 },
521
- { stream: streamOf([]) }
522
- );
523
- const handler = events.find((e) => e.name === "session/event").fn;
524
- const session = {
525
- id: "s-empty-summary",
526
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
527
- events: [userMessage("这一轮没有可保存的长期记忆", 1), { seq: 2, type: "turn/end" }]
528
- };
529
-
530
- await handler(session, { seq: 2, type: "turn/end" });
531
- assert.equal(calls.length, 1);
532
- assert.equal(store.count(), 0);
533
- assert.equal(store.getDistillCursor(session.id).last_seq, 2);
534
-
535
- await handler(session, { seq: 2, type: "turn/end" });
536
- assert.equal(calls.length, 1, "a valid empty summary must consume the window once");
537
- });
538
-
539
- test("advances the cursor to the window end for mixed valid and invalid entries", async () => {
540
- const valid = { type: "history", title: "有效摘要", content: "只保存这一条", importance: 3 };
541
- const { events, store, calls } = setup(
542
- { distillRateLimitIntervalMs: 0 },
543
- { stream: streamOf([valid, { type: "unknown", title: "无效摘要", content: "忽略" }, null]) }
544
- );
545
- const handler = events.find((e) => e.name === "session/event").fn;
546
- const session = {
547
- id: "s-mixed-summary",
548
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
549
- events: [userMessage("混合摘要窗口", 1), { seq: 2, type: "assistant/message" }, { seq: 3, type: "turn/end" }]
550
- };
551
-
552
- await handler(session, { seq: 3, type: "turn/end" });
553
- assert.equal(calls.length, 1);
554
- assert.equal(store.count(), 1);
555
- assert.equal(store.all()[0].title, valid.title);
556
- assert.equal(store.getDistillCursor(session.id).last_seq, 3);
557
-
558
- await handler(session, { seq: 3, type: "turn/end" });
559
- assert.equal(calls.length, 1, "a mixed summary must consume the window once");
560
- });
561
-
562
- test("distills only new seq events and keeps the recent tail when a window exceeds distillMaxChars", async () => {
563
- const { events, calls } = setup(
564
- { distillMaxChars: 80, distillRateLimitIntervalMs: 0 },
565
- { stream: streamOf([]) }
566
- );
567
- const handler = events.find((e) => e.name === "session/event").fn;
568
- const transcript = [
569
- userMessage(`OLD_BEGINNING ${"x".repeat(160)} OLD_RECENT`, 1),
570
- { seq: 2, type: "assistant/message", data: { message: { content: [{ type: "text", text: "旧回复" }] } } },
571
- { seq: 3, type: "turn/end" }
572
- ];
573
- const session = {
574
- id: "s-incremental",
575
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
576
- snapshotEvents: () => transcript
577
- };
578
-
579
- await handler(session, { seq: 3, type: "turn/end" });
580
- const firstInput = calls[0].messages.find((message) => message.role === "user").content[0].text;
581
-
582
- transcript.push(
583
- userMessage(`NEW_BEGINNING ${"y".repeat(160)} NEW_RECENT`, 4),
584
- { seq: 5, type: "assistant/message", data: { message: { content: [{ type: "text", text: "新回复" }] } } },
585
- { seq: 6, type: "turn/end" }
586
- );
587
- await handler(session, { seq: 6, type: "turn/end" });
588
- const secondInput = calls[1].messages.find((message) => message.role === "user").content[0].text;
589
-
590
- assert.notEqual(firstInput, secondInput, "successive distills must receive different windows");
591
- assert.ok(secondInput.includes("NEW_RECENT"), "the newest event content remains in the bounded window");
592
- assert.ok(!secondInput.includes("OLD_BEGINNING"), "the second window must not restart at the session beginning");
593
- assert.ok(!secondInput.includes("OLD_RECENT"), "the previous successful window must not be repeated");
594
- });
595
-
596
- test("dispose unsubscribes and stops later turn/end events from summarizing", async () => {
597
- const { events, summarizer, calls } = setup();
598
- const handler = events.find((e) => e.name === "session/event").fn;
599
- summarizer.dispose();
600
- // The ctx.on() disposer must have removed the listener.
601
- assert.ok(!events.some((e) => e.name === "session/event"));
602
- const session = {
603
- id: "s1",
604
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
605
- events: [userMessage("你好"), { seq: 2, type: "turn/end" }]
606
- };
607
- // Even a stale handler reference must not start a new LLM call.
608
- await handler(session, { seq: 2, type: "turn/end" });
609
- assert.equal(calls.length, 0);
610
- });
611
-
612
- test("excludes plugin-injected user/message events from summarization input", async () => {
613
- const { events, store, calls } = setup();
614
- const handler = events.find((e) => e.name === "session/event").fn;
615
- const session = {
616
- id: "s2",
617
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
618
- events: [
619
- {
620
- seq: 1,
621
- type: "user/message",
622
- data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 内容" }] }
623
- },
624
- userMessage("帮我看看这个报错"),
625
- { seq: 3, type: "turn/end" }
626
- ]
627
- };
628
- await handler(session, { seq: 3, type: "turn/end" });
629
- assert.equal(calls.length, 1);
630
- const userMessages = calls[0].messages.filter((m) => m.role === "user");
631
- assert.equal(userMessages.length, 1);
632
- assert.ok(!JSON.stringify(calls[0].messages).includes("AGENTS.md"));
633
- assert.equal(store.count(), 2);
634
- });
635
-
636
- test("aborted finish does not store entries and leaves the seq window retryable", async () => {
637
- let attempt = 0;
638
- const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
639
- stream() {
640
- attempt++;
641
- return (async function* () {
642
- yield { type: "block-start", block: { type: "text" } };
643
- if (attempt === 1) {
644
- yield { type: "text-delta", delta: "[]" };
645
- yield { type: "finish", kind: "aborted" };
646
- return;
647
- }
648
- yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试", content: "保留原窗口", importance: 3 }]) };
649
- yield { type: "finish", kind: "ok" };
650
- })();
651
- }
652
- });
653
- const handler = events.find((e) => e.name === "session/event").fn;
654
- const session = {
655
- id: "s3",
656
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
657
- events: [userMessage("继续", 1), { seq: 2, type: "turn/end" }]
658
- };
659
- await handler(session, { seq: 2, type: "turn/end" });
660
- assert.equal(calls.length, 1); // the stream was actually reached
661
- assert.equal(store.count(), 0);
662
- await handler(session, { seq: 2, type: "turn/end" });
663
- assert.equal(calls.length, 2, "an aborted window must be retried instead of being marked consumed");
664
- assert.equal(store.count(), 1);
665
- });
666
-
667
- test("a stream failure leaves the seq window retryable", async () => {
668
- let attempt = 0;
669
- const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
670
- stream() {
671
- attempt++;
672
- if (attempt === 1) {
673
- return (async function* () {
674
- throw new Error("temporary stream failure");
675
- })();
676
- }
677
- return streamOf([{ type: "history", title: "重试成功", content: "失败窗口未丢失", importance: 3 }])();
678
- }
679
- });
680
- const handler = events.find((e) => e.name === "session/event").fn;
681
- const session = {
682
- id: "s-stream-failure",
683
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
684
- events: [userMessage("保留失败窗口", 1), { seq: 2, type: "turn/end" }]
685
- };
686
-
687
- await handler(session, { seq: 2, type: "turn/end" });
688
- assert.equal(calls.length, 1);
689
- assert.equal(store.count(), 0);
690
- await handler(session, { seq: 2, type: "turn/end" });
691
- assert.equal(calls.length, 2, "a failed stream must be retried even without new events");
692
- assert.equal(store.count(), 1);
693
- });
694
-
695
- test("invalid summary JSON leaves the seq window retryable", async () => {
696
- let attempt = 0;
697
- const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
698
- stream() {
699
- attempt++;
700
- const output = attempt === 1 ? "not a JSON array" : "[]";
701
- return (async function* () {
702
- yield { type: "block-start", block: { type: "text" } };
703
- yield { type: "text-delta", delta: output };
704
- yield { type: "finish", kind: "ok" };
705
- })();
706
- }
707
- });
708
- const handler = events.find((e) => e.name === "session/event").fn;
709
- const session = {
710
- id: "s-parse-failure",
711
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
712
- events: [userMessage("保留解析窗口", 1), { seq: 2, type: "turn/end" }]
713
- };
714
-
715
- await handler(session, { seq: 2, type: "turn/end" });
716
- await handler(session, { seq: 2, type: "turn/end" });
717
- assert.equal(calls.length, 2, "a parse failure must not advance the seq cursor");
718
- assert.equal(store.count(), 0, "the valid empty retry remains a no-op");
719
- });
720
-
721
- test("a JSON-crashed window with salvageable objects is recovered, consumed once, and audited (issue #339)", async (t) => {
722
- const malformed = `[
723
- {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
724
- { " "type": "history", "title": "坏对象", "content": "语法错误" },
725
- {"type":"preference","title":"硬件","content":"只要 brushed brass"}
726
- ]`;
727
- const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
728
- stream() {
729
- return (async function* () {
730
- yield { type: "block-start", block: { type: "text" } };
731
- yield { type: "text-delta", delta: malformed };
732
- yield { type: "finish", kind: "ok" };
733
- })();
734
- }
735
- });
736
- t.after(() => { summarizer.dispose(); store.close(); });
737
- const handler = events.find((e) => e.name === "session/event").fn;
738
- const session = {
739
- id: "s-salvage",
740
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
741
- events: [userMessage("带语法错误的蒸馏窗口", 1), { seq: 2, type: "turn/end" }]
742
- };
743
-
744
- await handler(session, { seq: 2, type: "turn/end" });
745
- assert.equal(service.count(), 2, "the two intact objects are written, the broken one dropped");
746
- const titles = service.list().map((m) => m.title).sort();
747
- assert.deepEqual(titles, ["硬件", "预算"]);
748
- assert.equal(calls.length, 1, "salvage consumed the window: no same-window retry");
749
- const audit = service.listLlmAudits().find((r) => r.status === "success");
750
- assert.ok(audit, "salvaged run is audited as a success");
751
- assert.equal(audit.metadata?.json_salvaged, true, "salvage is observable in the audit trail");
752
-
753
- await handler(session, { seq: 2, type: "turn/end" });
754
- assert.equal(calls.length, 1, "the consumed window is not re-distilled");
755
- assert.equal(service.count(), 2, "no duplicate writes on the second turn/end");
756
- });
757
-
758
- test("a JSON-crashed window with nothing salvageable stays retryable (issue #339)", async (t) => {
759
- const hopeless = `[
760
- { " "type": "history", "title": "坏", "content": "语法错误" }
761
- ]`;
762
- let attempt = 0;
763
- const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
764
- stream() {
765
- const output = attempt++ === 0 ? hopeless : `[
766
- {"type":"history","title":"重试成功","content":"干净窗口","importance":3}
767
- ]`;
768
- return (async function* () {
769
- yield { type: "block-start", block: { type: "text" } };
770
- yield { type: "text-delta", delta: output };
771
- yield { type: "finish", kind: "ok" };
772
- })();
773
- }
774
- });
775
- t.after(() => { summarizer.dispose(); store.close(); });
776
- const handler = events.find((e) => e.name === "session/event").fn;
777
- const session = {
778
- id: "s-salvage-none",
779
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
780
- events: [userMessage("救不回的窗口", 1), { seq: 2, type: "turn/end" }]
781
- };
782
-
783
- await handler(session, { seq: 2, type: "turn/end" });
784
- assert.equal(service.count(), 0, "zero salvage is not a fake success");
785
- await handler(session, { seq: 2, type: "turn/end" });
786
- assert.equal(calls.length, 2, "the window stays retryable");
787
- assert.equal(service.count(), 1);
788
- assert.ok(service.listLlmAudits().some(
789
- (r) => r.status === "error" && r.error_message === "invalid summary JSON"
790
- ), "zero-salvage run keeps the error audit");
791
- });
792
-
793
- test("an all-invalid summary leaves the same window retryable until valid memories are saved", async (t) => {
794
- for (const invalid of [
795
- [{}],
796
- [{ type: "unknown", title: "无效类型", content: "不能消费窗口" }],
797
- [{ type: "history", title: " ", content: "不能消费窗口" }, null, "garbage"]
798
- ]) {
799
- await t.test(JSON.stringify(invalid), async (t) => {
800
- let attempt = 0;
801
- const valid = { type: "history", title: "重试成功", content: "全无效摘要后仍保留原窗口", importance: 3 };
802
- const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
803
- stream() {
804
- return streamOf(attempt++ === 0 ? invalid : [valid])();
805
- }
806
- });
807
- t.after(() => { summarizer.dispose(); store.close(); });
808
- const handler = events.find((e) => e.name === "session/event").fn;
809
- const session = {
810
- id: "s-all-invalid",
811
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
812
- events: [userMessage("保留原窗口", 1), { seq: 2, type: "turn/end" }]
813
- };
814
-
815
- await handler(session, { seq: 2, type: "turn/end" });
816
- assert.equal(service.count(), 0);
817
- await handler(session, { seq: 2, type: "turn/end" });
818
- assert.equal(calls.length, 2, "all-invalid entries must not consume the window");
819
- const firstTranscript = calls[0].messages.find((message) => message.role === "user").content[0].text;
820
- const retryTranscript = calls[1].messages.find((message) => message.role === "user").content[0].text;
821
- assert.equal(retryTranscript, firstTranscript, "retry receives the original transcript");
822
- assert.equal(service.count(), 1);
823
- assert.equal(service.list()[0].content, valid.content);
824
- assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some(
825
- (audit) => audit.status === "error" && audit.error_message === "invalid summary JSON"
826
- ), "an all-invalid summary must be audited as an error");
827
-
828
- await handler(session, { seq: 2, type: "turn/end" });
829
- assert.equal(calls.length, 2, "the successfully retried window is consumed once");
830
- assert.equal(service.count(), 1);
831
- });
832
- }
833
- });
834
-
835
- test("rolls back partial memory writes so retrying a failed window does not duplicate entries", async () => {
836
- const { events, store, service, calls } = setup(
837
- { distillRateLimitIntervalMs: 0 },
838
- {
839
- stream: streamOf([
840
- { type: "history", title: "第一条", content: "第一条内容", importance: 3 },
841
- { type: "decision", title: "第二条", content: "第二条内容", importance: 4 }
842
- ])
843
- }
844
- );
845
- const originalSave = service.saveWithDedupe.bind(service);
846
- let failOnSecondEntry = true;
847
- service.saveWithDedupe = (memory) => {
848
- const result = originalSave(memory);
849
- if (failOnSecondEntry && memory.title === "第二条") {
850
- failOnSecondEntry = false;
851
- throw new Error("simulated memory write failure");
852
- }
853
- return result;
854
- };
855
- const handler = events.find((e) => e.name === "session/event").fn;
856
- const session = {
857
- id: "s-partial-write",
858
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
859
- events: [userMessage("不要丢失这个窗口", 1), { seq: 2, type: "turn/end" }]
860
- };
861
-
862
- await handler(session, { seq: 2, type: "turn/end" });
863
- assert.equal(calls.length, 1);
864
- assert.equal(store.count(), 0, "a failed write transaction must leave no partial memory");
865
- assert.equal(store.getDistillCursor("s-partial-write"), undefined, "a failed write must not advance the cursor");
866
-
867
- await handler(session, { seq: 2, type: "turn/end" });
868
- assert.equal(calls.length, 2, "the failed seq window must be retried");
869
- assert.equal(store.count(), 2);
870
- assert.equal(store.getDistillCursor("s-partial-write").last_seq, 2);
871
- assert.equal(store.all().find((memory) => memory.title === "第一条")?.content, "第一条内容");
872
-
873
- await handler(session, { seq: 2, type: "turn/end" });
874
- assert.equal(calls.length, 2, "a successfully retried window must not be distilled again");
875
- assert.equal(store.count(), 2);
876
- });
877
-
878
- test("rolls back memory writes when persisting the cursor fails", async () => {
879
- const { events, store, service, calls } = setup(
880
- { distillRateLimitIntervalMs: 0 },
881
- { stream: streamOf([{ type: "history", title: "游标失败", content: "事务应回滚", importance: 3 }]) }
882
- );
883
- const originalSetCursor = service.setDistillCursor.bind(service);
884
- let failOnce = true;
885
- service.setDistillCursor = (sessionId, lastSeq) => {
886
- if (failOnce) {
887
- failOnce = false;
888
- throw new Error("simulated cursor write failure");
889
- }
890
- return originalSetCursor(sessionId, lastSeq);
891
- };
892
- const handler = events.find((e) => e.name === "session/event").fn;
893
- const session = {
894
- id: "s-cursor-write-failure",
895
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
896
- events: [userMessage("游标写入失败也不能丢窗口", 1), { seq: 2, type: "turn/end" }]
897
- };
898
-
899
- await handler(session, { seq: 2, type: "turn/end" });
900
- assert.equal(calls.length, 1);
901
- assert.equal(store.count(), 0, "cursor failure must roll back the memory write");
902
- assert.equal(store.getDistillCursor(session.id), undefined);
903
- const failedAudit = service.listLlmAudits({ source: "autoSummarize" }).find(
904
- (audit) => audit.error_message === "simulated cursor write failure"
905
- );
906
- assert.equal(failedAudit?.status, "error", "cursor failure must not be audited as success");
907
-
908
- await handler(session, { seq: 2, type: "turn/end" });
909
- assert.equal(calls.length, 2, "the failed cursor window must be retryable");
910
- assert.equal(store.count(), 1);
911
- assert.equal(store.getDistillCursor(session.id).last_seq, 2);
912
- });
913
-
914
- test("falls back to the in-memory cursor when service lacks setDistillCursor", async () => {
915
- // 旧版 service(#274 之前)没有持久化游标:缺方法必须降级为内存游标照常蒸馏,
916
- // 而不是抛错打断每轮蒸馏。方法存在但抛错走的是上一条回滚用例,两者不可混淆。
917
- const { events, store, service, calls } = setup(
918
- { distillRateLimitIntervalMs: 0 },
919
- { stream: streamOf([{ type: "history", title: "降级游标", content: "缺方法也能蒸馏", importance: 3 }]) }
920
- );
921
- delete service.setDistillCursor; // 模拟旧版 service:实例属性,直接删掉
922
- const handler = events.find((e) => e.name === "session/event").fn;
923
- const session = {
924
- id: "s-no-cursor-api",
925
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
926
- events: [userMessage("旧宿主没有游标接口", 1), { seq: 2, type: "turn/end" }]
927
- };
928
-
929
- await handler(session, { seq: 2, type: "turn/end" });
930
- assert.equal(calls.length, 1, "distillation must still run without a persisted cursor");
931
- assert.equal(store.count(), 1, "the memory itself must be saved");
932
- assert.equal(store.getDistillCursor(session.id), undefined, "nothing may be persisted without the API");
933
-
934
- await handler(session, { seq: 2, type: "turn/end" });
935
- assert.equal(calls.length, 1, "the in-memory cursor must prevent re-distillation in this process");
936
- });
937
-
938
- test("uses summarizeProvider/summarizeModel config override when set", async () => {
939
- const { events, calls } = setup({
940
- summarizeProvider: "aliyun",
941
- summarizeModel: "qwen3.6-plus"
942
- });
943
- const handler = events.find((e) => e.name === "session/event").fn;
944
- const session = {
945
- id: "s4",
946
- // Session header has a different model — config override should win.
947
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-v4-pro" } }),
948
- events: [userMessage("测试覆盖"), { seq: 2, type: "turn/end" }]
949
- };
950
- await handler(session, { seq: 2, type: "turn/end" });
951
- assert.equal(calls.length, 1);
952
- assert.equal(calls[0].provider, "aliyun");
953
- assert.equal(calls[0].model, "qwen3.6-plus");
954
- });
955
-
956
- test("falls back to session header when summarize config is empty", async () => {
957
- const { events, calls } = setup({
958
- summarizeProvider: "",
959
- summarizeModel: ""
960
- });
961
- const handler = events.find((e) => e.name === "session/event").fn;
962
- const session = {
963
- id: "s5",
964
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
965
- events: [userMessage("回退测试"), { seq: 2, type: "turn/end" }]
966
- };
967
- await handler(session, { seq: 2, type: "turn/end" });
968
- assert.equal(calls.length, 1);
969
- assert.equal(calls[0].provider, "deepseek");
970
- assert.equal(calls[0].model, "deepseek-chat");
971
- });
972
-
973
- // ── v0.7.11:智能调速器(429 保护)+ 完整转录/原子记忆 ─────────────────────
974
-
975
- test("serializes distill LLM calls across sessions (global queue, no concurrency)", async () => {
976
- const timeline = [];
977
- const { events, store } = setup({ distillRateLimitIntervalMs: 0 }, {
978
- stream() {
979
- return (async function* () {
980
- timeline.push(`start:${Date.now()}`);
981
- await new Promise((r) => setTimeout(r, 8));
982
- yield { type: "block-start", block: { type: "text" } };
983
- yield { type: "text-delta", delta: "[]" };
984
- yield { type: "finish", kind: "ok" };
985
- timeline.push(`end:${Date.now()}`);
986
- })();
987
- }
988
- });
989
- const handler = events.find((e) => e.name === "session/event").fn;
990
- const mkSession = (id) => ({
991
- id,
992
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
993
- events: [userMessage(`问题${id}`), { seq: 2, type: "turn/end" }]
994
- });
995
- // 两个会话几乎同时 turn/end → 必须排队,第二个请求不能与第一个并发。
996
- await Promise.all([
997
- handler(mkSession("a"), { seq: 2, type: "turn/end" }),
998
- handler(mkSession("b"), { seq: 2, type: "turn/end" })
999
- ]);
1000
- assert.equal(timeline.length, 4); // 每个流 start + end
1001
- const starts = timeline.filter((t) => t.startsWith("start")).map((t) => Number(t.slice(6)));
1002
- const ends = timeline.filter((t) => t.startsWith("end")).map((t) => Number(t.slice(4)));
1003
- assert.ok(starts[1] >= ends[0], "second distill must start only after the first finished (serial queue)");
1004
- });
1005
-
1006
- test("retries with exponential backoff on 429 and still stores entries", async () => {
1007
- let attempts = 0;
1008
- const { events, store } = setup(
1009
- { distillRateLimitRetries: 3, distillRateLimitBaseDelayMs: 5, distillRateLimitIntervalMs: 0 },
1010
- {
1011
- stream() {
1012
- return (async function* () {
1013
- attempts++;
1014
- if (attempts < 3) throw Object.assign(new Error("rate limit exceeded"), { status: 429 });
1015
- yield { type: "block-start", block: { type: "text" } };
1016
- yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试成功", content: "第三次请求成功", importance: 3 }]) };
1017
- yield { type: "finish", kind: "ok" };
1018
- })();
1019
- }
1020
- }
1021
- );
1022
- const handler = events.find((e) => e.name === "session/event").fn;
1023
- const session = {
1024
- id: "s9",
1025
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1026
- events: [userMessage("限流测试"), { seq: 2, type: "turn/end" }]
1027
- };
1028
- const startedAt = Date.now();
1029
- await handler(session, { seq: 2, type: "turn/end" });
1030
- // 429 两次 → 退避重试(5ms + 10ms),第三次成功入库。
1031
- assert.equal(attempts, 3);
1032
- assert.ok(Date.now() - startedAt >= 15, "backoff waits should be visible");
1033
- assert.equal(store.count(), 1);
1034
- assert.equal(store.all()[0].title, "重试成功");
1035
- });
1036
-
1037
- test("distills full transcript (tool calls, results, code output) with atomic-memory prompt", async () => {
1038
- const { events, calls } = setup();
1039
- const handler = events.find((e) => e.name === "session/event").fn;
1040
- const session = {
1041
- id: "s10",
1042
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1043
- events: [
1044
- userMessage("帮我修这个 bug"),
1045
- { seq: 2, type: "tool/call", data: { name: "Bash", arguments: "node test.js" } },
1046
- toolResultEvent("TypeError: x is not a function", true, 3),
1047
- codeDispatchEvent("fixed", false, 4),
1048
- { seq: 5, type: "turn/end" }
1049
- ]
1050
- };
1051
- await handler(session, { seq: 5, type: "turn/end" });
1052
- const transcript = JSON.stringify(calls[0].messages);
1053
- assert.ok(transcript.includes("修这个 bug"));
1054
- assert.ok(transcript.includes("TypeError: x is not a function"));
1055
- assert.ok(transcript.includes("代码执行"));
1056
- // 原子记忆 prompt:不再"硬压 2-3 条",而是按需多提、贴近原始细节。
1057
- assert.ok(calls[0].messages[0].content[0].text.includes("原子记忆"));
1058
- });
1059
-
1060
- test("collects real tool result and code dispatch payloads with failure status", async () => {
1061
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1062
- const handler = events.find((e) => e.name === "session/event").fn;
1063
- const session = {
1064
- id: "s-real-payloads",
1065
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1066
- events: [
1067
- userMessage("检查真实事件形状", 1),
1068
- toolResultEvent("TypeError: tool failed", true, 2),
1069
- codeDispatchEvent("代码执行结果", false, 3),
1070
- { seq: 4, type: "turn/end" }
1071
- ]
1072
- };
1073
-
1074
- await handler(session, { seq: 4, type: "turn/end" });
1075
- const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1076
- assert.match(transcript, /工具结果(失败):TypeError: tool failed/);
1077
- assert.match(transcript, /代码执行(成功):代码执行结果/);
1078
- });
1079
-
1080
- test("collects tool results delivered as role:tool messages, keeping the legacy block shape", async () => {
1081
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1082
- const handler = events.find((e) => e.name === "session/event").fn;
1083
- const session = {
1084
- id: "s-tool-role-shape",
1085
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1086
- events: [
1087
- userMessage("检查工具结果两种形状", 1),
1088
- toolResultEvent("旧形状载荷", false, 2),
1089
- toolRoleResultEvent("新形状载荷", false, 3),
1090
- toolRoleResultEvent("TypeError: tool crashed", true, 4),
1091
- { seq: 5, type: "turn/end" }
1092
- ]
1093
- };
1094
-
1095
- await handler(session, { seq: 5, type: "turn/end" });
1096
- const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1097
- assert.match(transcript, /工具结果(成功):旧形状载荷/);
1098
- assert.match(transcript, /工具结果(成功):新形状载荷/);
1099
- assert.match(transcript, /工具结果(失败):TypeError: tool crashed/);
1100
- });
1101
-
1102
- test("keeps prefix trimming for tool results delivered as role:tool messages", async () => {
1103
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1104
- const handler = events.find((e) => e.name === "session/event").fn;
1105
- const session = {
1106
- id: "s-tool-role-trim",
1107
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1108
- events: [
1109
- userMessage("检查新形状截断", 1),
1110
- toolRoleResultEvent(`OUT_START ${"c".repeat(550)} OUT_END`, false, 2),
1111
- { seq: 3, type: "turn/end" }
1112
- ]
1113
- };
1114
-
1115
- await handler(session, { seq: 3, type: "turn/end" });
1116
- const transcript = JSON.stringify(calls[0].messages);
1117
- assert.ok(transcript.includes("OUT_START"));
1118
- assert.ok(!transcript.includes("OUT_END"), "role:tool 结果同样走 500 字符前缀截断");
1119
- });
1120
-
1121
- test("collects subagent delivery once across inbox and user-message views", async () => {
1122
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1123
- const handler = events.find((e) => e.name === "session/event").fn;
1124
- const delivery = inboxMessage("delivery-1", "subagent-settled", "子会话 closing message");
1125
- const agentMessage = inboxMessage("delivery-2", "agent-message", "子代理补充报告");
1126
- const injectedInstruction = inboxMessage("instruction-1", "agent-instructions", "不要把这条注入指令沉淀为记忆");
1127
- const session = {
1128
- id: "s-agent-delivery",
1129
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1130
- events: [
1131
- userMessage("检查子会话交付", 1),
1132
- { seq: 2, type: "agent/inbox/spliced", data: { inserted: [delivery, agentMessage, injectedInstruction] } },
1133
- { seq: 3, type: "user/message", data: delivery },
1134
- { seq: 4, type: "turn/end" }
1135
- ]
1136
- };
1137
-
1138
- await handler(session, { seq: 4, type: "turn/end" });
1139
- const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1140
- assert.equal(transcript.match(/子会话 closing message/g)?.length, 1);
1141
- assert.equal(transcript.match(/子代理补充报告/g)?.length, 1);
1142
- assert.ok(!transcript.includes("不要把这条注入指令沉淀为记忆"));
1143
- });
1144
-
1145
- test("keeps prefix trimming for tool arguments and tool results", async () => {
1146
- const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1147
- const handler = events.find((e) => e.name === "session/event").fn;
1148
- const session = {
1149
- id: "s-trim",
1150
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1151
- events: [
1152
- userMessage("检查截断", 1),
1153
- { seq: 2, type: "tool/call", data: { name: "Bash", arguments: `ARG_START ${"a".repeat(350)} ARG_END` } },
1154
- toolResultEvent(`OUT_START ${"b".repeat(550)} OUT_END`, false, 3),
1155
- { seq: 4, type: "turn/end" }
1156
- ]
1157
- };
1158
- await handler(session, { seq: 4, type: "turn/end" });
1159
- const transcript = JSON.stringify(calls[0].messages);
1160
- assert.ok(transcript.includes("ARG_START"));
1161
- assert.ok(!transcript.includes("ARG_END"), "tool arguments keep their existing prefix trim");
1162
- assert.ok(transcript.includes("OUT_START"));
1163
- assert.ok(!transcript.includes("OUT_END"), "tool results keep their existing prefix trim");
1164
- });
1165
-
1166
- test("distill excludes private assistant reasoning blocks from the transcript", async () => {
1167
- const { events, calls } = setup();
1168
- const handler = events.find((e) => e.name === "session/event").fn;
1169
- const session = {
1170
- id: "s12",
1171
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1172
- events: [
1173
- userMessage("解释一下这段代码"),
1174
- {
1175
- type: "assistant/message",
1176
- data: {
1177
- message: {
1178
- content: [
1179
- { type: "reasoning", text: "私有推理:内部权衡过程不该进记忆" },
1180
- { type: "text", text: "这段代码有死循环,第 3 行 while 条件恒真。" }
1181
- ]
1182
- }
1183
- }
1184
- },
1185
- { seq: 3, type: "turn/end" }
1186
- ]
1187
- };
1188
- await handler(session, { seq: 3, type: "turn/end" });
1189
- const transcript = JSON.stringify(calls[0].messages);
1190
- assert.ok(transcript.includes("有死循环,第 3 行"), "public assistant text still distills");
1191
- assert.ok(!transcript.includes("私有推理"), "private reasoning must never enter the distill context");
1192
- });
1193
-
1194
- test("codingRetrospect stores coding memory types in the coding memory type set", async () => {
1195
- const { events, store, calls } = setup({ codingRetrospect: true }, {
1196
- stream() {
1197
- return (async function* () {
1198
- yield { type: "block-start", block: { type: "text" } };
1199
- yield { type: "text-delta", delta: JSON.stringify([
1200
- { type: "rejected_solution", title: "弃用方案", content: "A 方案被否决,改用 B", importance: 4 }
1201
- ]) };
1202
- yield { type: "finish", kind: "ok" };
1203
- })();
1204
- }
1205
- });
1206
- const handler = events.find((e) => e.name === "session/event").fn;
1207
- const session = {
1208
- id: "s11",
1209
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1210
- events: [userMessage("编码任务"), { seq: 2, type: "turn/end" }]
1211
- };
1212
- await handler(session, { seq: 2, type: "turn/end" });
1213
- assert.equal(store.count(), 1);
1214
- const m = store.all()[0];
1215
- assert.equal(m.type, "rejected_solution");
1216
- // 读取侧门控靠 m.type(rejected_solution/pitfall/constraint ∈ INJECT_TYPES),
1217
- // sanitizeTags 不认 `type:` 前缀,所以不给编码记忆打 tag(会清空 tags 列)。
1218
- assert.deepEqual(m.tags, []);
1219
- // 编码模式用编码 prompt(含 rejected_solution 类型说明)。
1220
- assert.ok(calls[0].messages[0].content[0].text.includes("rejected_solution"));
1221
- });
1222
-
1223
- // ── Issue #127:节流 / 条数上限 / 同会话去重 ────────────────────────────────
1224
-
1225
- /** A distill stream that always returns exactly these entries. */
1226
- function streamOf(entries) {
1227
- return () => (async function* () {
1228
- yield { type: "block-start", block: { type: "text" } };
1229
- yield { type: "text-delta", delta: JSON.stringify(entries) };
1230
- yield { type: "finish", kind: "ok" };
1231
- })();
1232
- }
1233
-
1234
- function sessionFor(id) {
1235
- return {
1236
- id,
1237
- requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1238
- events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
1239
- };
1240
- }
1241
-
1242
- test("issue#127: min-interval gate suppresses the second turn/end and audits it as skipped", async () => {
1243
- const { events, store, calls } = setup({ summarizeMinIntervalMinutes: 30 });
1244
- const handler = events.find((e) => e.name === "session/event").fn;
1245
- const session = sessionFor("t1");
1246
- await handler(session, { seq: 2, type: "turn/end" });
1247
- assert.equal(calls.length, 1, "the first turn/end distills");
1248
- assert.equal(store.count(), 2);
1249
- await handler(session, { seq: 3, type: "turn/end" });
1250
- assert.equal(calls.length, 1, "the second turn/end inside the window makes no LLM call");
1251
- assert.equal(store.count(), 2, "nothing new is stored");
1252
- // 可观测性:被节流不再静默——留一行 status='skipped',一条 SQL 可自查。
1253
- const audits = store.listLlmAudits({ source: "autoSummarize" });
1254
- assert.ok(
1255
- audits.some((a) => a.status === "skipped" && a.error_message === "min-interval"),
1256
- "the suppression is visible as a status='skipped' audit row"
1257
- );
1258
- });
1259
-
1260
- test("issue#127: min-interval 0 still distills every new event window", async () => {
1261
- const { events, calls } = setup({ summarizeMinIntervalMinutes: 0 });
1262
- const handler = events.find((e) => e.name === "session/event").fn;
1263
- const session = sessionFor("t2");
1264
- await handler(session, { seq: 2, type: "turn/end" });
1265
- session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
1266
- await handler(session, { seq: 4, type: "turn/end" });
1267
- assert.equal(calls.length, 2, "0 = no gate (zero behavior change)");
1268
- });
1269
-
1270
- test("issue#127: the interval gate is per-session (one session never throttles another)", async () => {
1271
- const { events, calls } = setup({ summarizeMinIntervalMinutes: 30 });
1272
- const handler = events.find((e) => e.name === "session/event").fn;
1273
- await handler(sessionFor("ta"), { seq: 2, type: "turn/end" });
1274
- await handler(sessionFor("tb"), { seq: 2, type: "turn/end" });
1275
- assert.equal(calls.length, 2, "a different session is not throttled by another's lastRunAt");
1276
- });
1277
-
1278
- test("issue#127: summarizeMaxEntriesPerRun caps the stored entries and audits parsed/capped", async () => {
1279
- const { events, store } = setup({ summarizeMaxEntriesPerRun: 1 }, {
1280
- stream: streamOf([
1281
- { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
1282
- { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
1283
- ])
1284
- });
1285
- const handler = events.find((e) => e.name === "session/event").fn;
1286
- await handler(sessionFor("t3"), { seq: 2, type: "turn/end" });
1287
- assert.equal(store.count(), 1, "only the first capped entry is stored");
1288
- const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
1289
- assert.equal(audit.metadata?.parsed, 2, "the raw parsed count is kept for tuning the cap");
1290
- assert.equal(audit.metadata?.capped, 1);
1291
- });
1292
-
1293
- test("issue#127: title dedupe absorbs a normalized-same title", async () => {
1294
- const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
1295
- stream: streamOf([{ type: "pitfall", title: "Win7 OpenSSH 失效", content: "第二次记录", importance: 4 }])
1296
- });
1297
- // 既有条目:同会话、标题归一化后全等(大小写与空白差异)
1298
- const seeded = service.saveWithDedupe({
1299
- type: "pitfall", title: "win7 openssh 失效", content: "第一次记录", source: "session:t5"
1300
- }).memory;
1301
- const handler = events.find((e) => e.name === "session/event").fn;
1302
- await handler(sessionFor("t5"), { seq: 2, type: "turn/end" });
1303
- assert.equal(store.count(), 1, "the normalized-same title merged instead of creating a row");
1304
- assert.ok(store.getById(seeded.id).content.includes("第二次记录"), "content appended to the existing row");
1305
- });
1306
-
1307
- test("issue#127: title dedupe leaves a rephrase alone (it only catches an exact same title)", async () => {
1308
- // 同一事实的另一种措辞 → title 档不命中,正常新建。这条界定了 title 档的边界,
1309
- // 避免被误当成 vector 档的替代(cos≥0.85 的簇里「归一化标题全同」为 0 个)。
1310
- const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
1311
- stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "新记录", importance: 4 }])
1312
- });
1313
- service.saveWithDedupe({
1314
- type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "旧记录", source: "session:other"
1315
- });
1316
- const handler = events.find((e) => e.name === "session/event").fn;
1317
- await handler(sessionFor("t7"), { seq: 2, type: "turn/end" });
1318
- assert.equal(store.count(), 2, "a rephrase (different title) is not merged by the title tier");
1319
- });
1320
-
1321
- test("issue#127: vector dedupe merges a same-fact rephrase and keeps content_history", async () => {
1322
- const embedder = {
1323
- ready: true,
1324
- embedSingle: async (text) => (String(text).includes("OpenSSH") ? [1, 0, 0] : [0, 1, 0])
1325
- };
1326
- const { events, store, service } = setup(
1327
- { summarizeDedupeMode: "vector", summarizeDedupeMinSim: 0.92 },
1328
- { stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "重复踩坑", importance: 4 }]) }
1329
- );
1330
- service.setEmbedder(embedder);
1331
- // 同一事实的另一种措辞,已有向量 → 余弦 1.0 ≥ 0.92
1332
- const seeded = service.saveWithDedupe({
1333
- type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "第一次记录", source: "session:t8"
1334
- }).memory;
1335
- store.setEmbedding(seeded.id, [1, 0, 0]);
1336
- const handler = events.find((e) => e.name === "session/event").fn;
1337
- await handler(sessionFor("t8"), { seq: 2, type: "turn/end" });
1338
- assert.equal(store.count(), 1, "no new row: the rephrase was absorbed by cosine");
1339
- const merged = store.getById(seeded.id);
1340
- assert.ok(merged.content.includes("重复踩坑"), "content appended into the existing row");
1341
- assert.ok((merged.content_history ?? []).length >= 1, "content_history keeps the prior version");
1342
- const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
1343
- assert.equal(audit.metadata?.deduped, 1);
1344
- assert.equal(audit.metadata?.mode, "vector");
1345
- });
1346
-
1347
- test("issue#127: no embedder means vector dedupe silently falls back to a normal write", async () => {
1348
- const { events, store } = setup(
1349
- { summarizeDedupeMode: "vector" },
1350
- { stream: streamOf([{ type: "pitfall", title: "无关的一条", content: "内容", importance: 4 }]) }
1351
- );
1352
- const handler = events.find((e) => e.name === "session/event").fn;
1353
- await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
1354
- assert.equal(store.count(), 1, "dedupe failure never blocks the write");
1355
- });
1356
-
1357
- test("issue#127: the five summarize knobs are whitelisted and range-checked", () => {
1358
- const store = createStore(":memory:");
1359
- const settings = createSettings(store.db);
1360
- const merged = settings.setFeatureFlags({
1361
- summarizeMinIntervalMinutes: 30,
1362
- summarizeMaxEntriesPerRun: 5,
1363
- summarizeDedupeMode: "vector",
1364
- summarizeDedupeMinSim: 0.9,
1365
- summarizeDedupeWindowHours: 12
1366
- });
1367
- assert.equal(merged.summarizeMinIntervalMinutes, 30);
1368
- assert.equal(merged.summarizeDedupeMode, "vector");
1369
- assert.equal(merged.summarizeDedupeMinSim, 0.9, "float thresholds round-trip (new numbers whitelist)");
1370
- // 面板/API 写入走同一套逐键校验:越界与非法枚举必须被拒。
1371
- assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMinSim: 0.3 }), /number in \[0.5, 0.99\]/);
1372
- assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMode: "semantic" }), /one of: off, title, vector/);
1373
- store.close();
1374
- });
1375
-
1376
- // ── Issue #127 补测:fail-safe 与打点回滚的边界路径 ────────────────────────
1377
-
1378
- test("issue#127: a throwing audit writer never breaks the min-interval skip path", async () => {
1379
- const { events, store, service } = setup({ summarizeMinIntervalMinutes: 30 });
1380
- const handler = events.find((e) => e.name === "session/event").fn;
1381
- const session = sessionFor("t10");
1382
- await handler(session, { seq: 2, type: "turn/end" });
1383
- assert.equal(store.count(), 2);
1384
- // 审计写入器抛错:跳过路径必须吞掉异常——审计是观测手段,绝不能反过来打断蒸馏。
1385
- service.saveLlmAudit = () => { throw new Error("audit sink down"); };
1386
- await handler(session, { seq: 3, type: "turn/end" });
1387
- assert.equal(store.count(), 2, "the throttled turn stays a no-op instead of rejecting");
1388
- });
1389
-
1390
- test("issue#127: an aborted run rolls the interval claim back so the next turn is admitted", async () => {
1391
- // 间隔取毫秒级小数值(schema 的粒度是 1 分钟,测试直接构造 config 绕开它),
1392
- // 这样"回滚到上一次打点"与"保留本次打点"才有可观察差异:
1393
- // 回滚 → 下一次落在 T1+gap 之外,应被接纳;
1394
- // 不滚 → 距本次打点才几毫秒,必被挡。
1395
- // 若沿用 gap=0,两种实现的对外行为完全相同,测试无法发现 else 分支写错。
1396
- let call = 0;
1397
- const { events } = setup({ summarizeMinIntervalMinutes: 0.01, distillRateLimitIntervalMs: 0 }, {
1398
- stream() {
1399
- call++;
1400
- if (call === 2) {
1401
- return (async function* () {
1402
- yield { type: "block-start", block: { type: "text" } };
1403
- yield { type: "text-delta", delta: "[]" };
1404
- yield { type: "finish", kind: "aborted" };
1405
- })();
1406
- }
1407
- return (async function* () {
1408
- yield { type: "block-start", block: { type: "text" } };
1409
- yield { type: "text-delta", delta: JSON.stringify([{ type: "decision", title: `第${call}条`, content: "内容", importance: 3 }]) };
1410
- yield { type: "finish", kind: "ok" };
1411
- })();
1412
- }
1413
- });
1414
- const handler = events.find((e) => e.name === "session/event").fn;
1415
- const session = sessionFor("t11");
1416
- await handler(session, { seq: 2, type: "turn/end" }); // T1:成功,占用间隔
1417
- assert.equal(call, 1, "the first turn distills");
1418
- await new Promise((r) => setTimeout(r, 700)); // 越过 600ms 间隔窗
1419
- session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
1420
- await handler(session, { seq: 4, type: "turn/end" }); // T2:aborted → 回滚到 T1
1421
- assert.equal(call, 2, "the aborted turn still reached the LLM call");
1422
- await handler(session, { seq: 4, type: "turn/end" }); // 立刻再发:只有回滚到 T1 才会被接纳
1423
- assert.equal(call, 3, "the aborted run did not consume the interval window");
1424
- });
1425
-
1426
- test("issue#127: a throwing candidate lookup degrades to undefined (service fail-safe)", async () => {
1427
- const { service } = setup({ summarizeDedupeMode: "vector" });
1428
- // 候选查询在 try 内抛错 → findSessionDuplicate 吞掉并返回 undefined(不向外抛)。
1429
- const hostile = { get type() { throw new Error("store down"); }, title: "标题", content: "内容" };
1430
- const hit = await service.findSessionDuplicate(hostile, { mode: "vector", source: "session:z" });
1431
- assert.equal(hit, undefined, "a failed lookup yields no duplicate instead of throwing");
1432
- });
1433
-
1434
- test("issue#127: a dedupe lookup that yields nothing still lands the entry (never dropped)", async () => {
1435
- // 走真实的 session/event 落库路径。只让去重判定不可用(service 层为何返回 undefined
1436
- // 由上一条覆盖)——蒸馏必须照常写入,不能因为拿不到去重结论就把这一条丢掉。
1437
- const { events, store, service } = setup(
1438
- { summarizeDedupeMode: "vector" },
1439
- { stream: streamOf([{ type: "decision", title: "必须落库", content: "内容", importance: 3 }]) }
1440
- );
1441
- service.findSessionDuplicate = async () => undefined;
1442
- const handler = events.find((e) => e.name === "session/event").fn;
1443
- await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
1444
- assert.equal(store.count(), 1, "the entry is stored through the normal write path");
1445
- assert.equal(store.all()[0].title, "必须落库");
1446
- });
1447
-
1448
- test("parseSummaryJson salvage only takes direct children of the outer array (issue #339 review)", () => {
1449
- // 嵌套子数组里的对象不捞(模型没把它当顶层记忆产出),坏对象照旧丢弃。
1450
- const nested = `[[{"type":"history","title":"nested","content":"kept"}], { " "type":"history"}]`;
1451
- assert.deepEqual(parseSummaryJson(nested), []);
1452
- });
1453
-
1454
- test("parseSummaryJson salvage does not turn string-embedded objects into memories (issue #339 review)", () => {
1455
- // 全数组解析失败(第二对象坏)+ 第一对象的 content 字符串里嵌着假记忆形状:
1456
- // 盲扫不得把属性值对象当独立记忆——外层对象整体才是直接子项。
1457
- const raw = `[
1458
- {"type":"history","title":"good","content":"note { \\"type\\": \\"history\\", \\"title\\": \\"fake\\" }"},
1459
- { " "type": "history", "title": "坏", "content": "x" }
1460
- ]`;
1461
- const parsed = parseSummaryJson(raw);
1462
- assert.equal(parsed.length, 1);
1463
- assert.equal(parsed[0].title, "good");
1464
- assert.match(parsed[0].content, /fake/);
1465
- });
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createStore } from "../src/store.js";
7
+ import { createService } from "../src/service.js";
8
+ import { createSummarizer, parseSummaryJson, parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "../src/summarize.js";
9
+ import { createSettings } from "../src/settings.js";
10
+
11
+ // Issue #239 第 4 项:可注入的假时钟 + 定时器——错峰队列的测试必须能「推进时间」,
12
+ // 真实 setTimeout 会让补跑路径要么测不到、要么拖慢测试。
13
+ function fakeClock(start) {
14
+ let current = start.getTime();
15
+ const timers = [];
16
+ const deps = {
17
+ now: () => current,
18
+ setTimeoutFn: (fn, ms) => {
19
+ const entry = { fn, at: current + ms };
20
+ timers.push(entry);
21
+ return entry;
22
+ },
23
+ clearTimeoutFn: (entry) => {
24
+ const i = timers.indexOf(entry);
25
+ if (i !== -1) timers.splice(i, 1);
26
+ }
27
+ };
28
+ const flush = async () => {
29
+ // 补跑是异步链(enqueueDistill → stream):推完时间要给微任务与一轮宏任务机会。
30
+ for (let i = 0; i < 4; i++) await new Promise((resolve) => setTimeout(resolve, 0));
31
+ };
32
+ return {
33
+ deps,
34
+ pending: () => timers.length,
35
+ async advanceTo(date) {
36
+ current = date.getTime();
37
+ for (const entry of [...timers].sort((a, b) => a.at - b.at)) {
38
+ if (entry.at > current) continue;
39
+ timers.splice(timers.indexOf(entry), 1);
40
+ entry.fn();
41
+ }
42
+ await flush();
43
+ },
44
+ async advance(ms) {
45
+ await this.advanceTo(new Date(current + ms));
46
+ }
47
+ };
48
+ }
49
+
50
+ function setup(over = {}, opts = {}) {
51
+ const store = opts.store ?? createStore(":memory:");
52
+ const service = createService({ store, mirror: null, config: {} });
53
+ const events = [];
54
+ const calls = [];
55
+ const ctx = {
56
+ on(name, fn) {
57
+ events.push({ name, fn });
58
+ return () => {
59
+ const i = events.findIndex((e) => e.name === name && e.fn === fn);
60
+ if (i !== -1) events.splice(i, 1);
61
+ };
62
+ },
63
+ llm: {
64
+ stream(options) {
65
+ calls.push(options);
66
+ if (opts.stream) return opts.stream(options);
67
+ const json = JSON.stringify([
68
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
69
+ { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
70
+ ]);
71
+ return (async function* () {
72
+ yield { type: "block-start", block: { type: "text" } };
73
+ yield { type: "text-delta", delta: json };
74
+ yield { type: "block-end", block: { type: "text" } };
75
+ yield { type: "finish", kind: "ok" };
76
+ })();
77
+ }
78
+ }
79
+ };
80
+ const config = { autoSummarize: true, ...over };
81
+ const summarizer = createSummarizer(ctx, service, config, opts.deps ?? {});
82
+ return { store, service, events, calls, summarizer };
83
+ }
84
+
85
+ // A realistic direct human prompt event (source.kind === "user").
86
+ function userMessage(text, seq) {
87
+ const event = {
88
+ type: "user/message",
89
+ data: { source: { kind: "user" }, content: [{ type: "text", text }] }
90
+ };
91
+ if (seq !== undefined) event.seq = seq;
92
+ return event;
93
+ }
94
+
95
+ function toolResultEvent(text, isError, seq) {
96
+ return {
97
+ seq,
98
+ type: "tool/result",
99
+ data: {
100
+ message: {
101
+ source: { kind: "tool", callId: `call-${seq}` },
102
+ content: [{
103
+ type: "tool-result",
104
+ toolCallId: `call-${seq}`,
105
+ isError,
106
+ content: [{ type: "text", text }]
107
+ }]
108
+ }
109
+ }
110
+ };
111
+ }
112
+
113
+ // DSH 0.2.x 内核对工具结果的实际投递形状:内核给的是 `role:"tool"` 消息本身,
114
+ // content 里只有 `type:"text"` 块、isError 挂在消息上,不再有 tool-result 块。
115
+ // 蒸馏曾只认旧块形状,于是真实会话里的工具输出与工具报错全部被静默丢弃。
116
+ function toolRoleResultEvent(text, isError, seq) {
117
+ return {
118
+ seq,
119
+ type: "tool/result",
120
+ data: {
121
+ message: {
122
+ role: "tool",
123
+ source: { kind: "tool", callId: `call-${seq}` },
124
+ toolCallId: `call-${seq}`,
125
+ content: [{ type: "text", text }],
126
+ isError
127
+ }
128
+ }
129
+ };
130
+ }
131
+
132
+ function codeDispatchEvent(text, isError, seq) {
133
+ return {
134
+ seq,
135
+ type: "tool/code-dispatch",
136
+ data: {
137
+ isError,
138
+ content: [{ type: "text", text }]
139
+ }
140
+ };
141
+ }
142
+
143
+ function inboxMessage(id, kind, text) {
144
+ return {
145
+ id,
146
+ role: "user",
147
+ source: { kind },
148
+ content: [{ type: "text", text }]
149
+ };
150
+ }
151
+
152
+ test("parseSummaryJson extracts valid entries and skips malformed ones", () => {
153
+ const parsed = parseSummaryJson(`前导文字 {"a":1}
154
+ [
155
+ {"type":"decision","title":"t1","content":"c1","importance":4},
156
+ {"type":"nonsense","title":"bad","content":"x"},
157
+ "garbage",
158
+ {"type":"preference","title":"t2","content":"c2","importance":2}
159
+ ]`);
160
+ assert.equal(parsed.length, 2);
161
+ assert.equal(parsed[0].type, "decision");
162
+ assert.equal(parsed[1].type, "preference");
163
+ });
164
+
165
+ // --- issue #339 / E8 考卷:JSON 崩溃窗口的 salvage(失败路径修复,默认开)-----
166
+ // E8 实测 10-20% 的窗口输出了含约束的完整对象,只因中段一处语法错误被整窗
167
+ // JSON.parse 拒收 → 生产行为 = 游标卡死 + 温度 0 重试同错 = 静默丢失。
168
+
169
+ test("parseSummaryJson salvages complete objects from a malformed array (issue #339)", () => {
170
+ // E8 现场同款:闭合数组、中段对象含杂散引号(stray quote)。
171
+ const raw = `[
172
+ {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
173
+ { " "type": "history", "title": "坏对象", "content": "语法错误" },
174
+ {"type":"preference","title":"硬件","content":"只要 brushed brass"}
175
+ ]`;
176
+ const parsed = parseSummaryJson(raw);
177
+ assert.equal(parsed.length, 2, "the broken middle object is dropped, both survivors kept");
178
+ assert.equal(parsed[0].content, "总额 $12,400");
179
+ assert.equal(parsed[1].title, "硬件");
180
+ });
181
+
182
+ test("parseSummaryJson salvage finding nothing stays a failure (no fake empty success)", () => {
183
+ // 只有一个语法坏掉的对象 → 救回 0 条:不得按「显式空数组」放行消费窗口。
184
+ const raw = `[
185
+ { " "type": "history", "title": "坏", "content": "语法错误" }
186
+ ]`;
187
+ const parsed = parseSummaryJson(raw);
188
+ assert.deepEqual(parsed, []);
189
+ });
190
+
191
+ test("subscribes to session/event when autoSummarize enabled", () => {
192
+ const { events } = setup();
193
+ assert.ok(events.some((e) => e.name === "session/event"));
194
+ });
195
+
196
+ test("does not subscribe when autoSummarize disabled", () => {
197
+ const { events } = setup({ autoSummarize: false });
198
+ assert.ok(!events.some((e) => e.name === "session/event"));
199
+ });
200
+
201
+ test("turn/end event triggers summarization and stores entries", async () => {
202
+ const { events, store } = setup();
203
+ const handler = events.find((e) => e.name === "session/event").fn;
204
+ const session = {
205
+ id: "s1",
206
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
207
+ events: [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
208
+ };
209
+ await handler(session, { seq: 2, type: "turn/end" });
210
+ assert.equal(store.count(), 2);
211
+ const all = store.all();
212
+ assert.ok(all.some((m) => m.type === "decision"));
213
+ assert.ok(all.some((m) => m.type === "preference"));
214
+ });
215
+
216
+ test("skips summarization for events other than turn/end", async () => {
217
+ const { events, store, calls } = setup();
218
+ const handler = events.find((e) => e.name === "session/event").fn;
219
+ const session = { id: "s1", requestHeader: () => ({ config: {} }), events: [] };
220
+ await handler(session, { seq: 1, type: "user/message" });
221
+ assert.equal(store.count(), 0);
222
+ assert.equal(calls.length, 0);
223
+ });
224
+
225
+ // DSH ≥0.1.2-rc removed Session.events; the session object only exposes
226
+ // snapshotEvents(). Regression for #59: with only the old .events path the
227
+ // collector saw an empty log and no LLM call ever happened.
228
+ test("reads events from snapshotEvents() when Session.events is absent", async () => {
229
+ const { events, store, calls } = setup();
230
+ const handler = events.find((e) => e.name === "session/event").fn;
231
+ const session = {
232
+ id: "s1",
233
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
234
+ snapshotEvents: () => [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
235
+ };
236
+ await handler(session, { seq: 2, type: "turn/end" });
237
+ assert.equal(calls.length, 1, "an LLM call must be made");
238
+ assert.equal(store.count(), 2);
239
+ });
240
+
241
+ // --- Issue #239:成本感知级联(零 LLM 预判)与有界检查点 ---------------------
242
+ // 验收对齐 #239:日志可见 skip 原因、默认配置下行为零变化、开启后调用量下降。
243
+
244
+ test("#239 预判默认关:阈值为 0 时不改变现状,照常发起调用", async () => {
245
+ const { events, calls, store } = setup();
246
+ const handler = events.find((e) => e.name === "session/event").fn;
247
+ const session = {
248
+ id: "s239-default",
249
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
250
+ events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
251
+ };
252
+ await handler(session, { seq: 2, type: "turn/end" });
253
+ assert.equal(calls.length, 1, "默认不预判:再短的窗口也照常蒸馏");
254
+ assert.equal(store.count(), 2);
255
+ });
256
+
257
+ test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费游标", async () => {
258
+ const { events, calls, service, store } = setup({ summarizeMinWindowChars: 1000 });
259
+ const handler = events.find((e) => e.name === "session/event").fn;
260
+ const session = {
261
+ id: "s239-small",
262
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
263
+ events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
264
+ };
265
+ await handler(session, { seq: 2, type: "turn/end" });
266
+ assert.equal(calls.length, 0, "短窗口不得发起 LLM 调用");
267
+ assert.equal(store.count(), 0, "被拦下就不该写入记忆");
268
+ const rows = service.listLlmAudits();
269
+ assert.equal(rows[0].status, "skipped");
270
+ assert.equal(rows[0].error_message, "window-too-small", "skip 原因必须可观测");
271
+
272
+ await handler(session, { seq: 2, type: "turn/end" });
273
+ assert.equal(calls.length, 0);
274
+ assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计");
275
+ });
276
+
277
+ test("#239 窗口达到阈值时预判放行(不误杀)", async () => {
278
+ const { events, calls } = setup({ summarizeMinWindowChars: 5 });
279
+ const handler = events.find((e) => e.name === "session/event").fn;
280
+ const session = {
281
+ id: "s239-pass",
282
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
283
+ events: [userMessage("这是一段足够长的用户输入", 1), { seq: 2, type: "turn/end" }]
284
+ };
285
+ await handler(session, { seq: 2, type: "turn/end" });
286
+ assert.equal(calls.length, 1);
287
+ });
288
+
289
+ test("#239 每会话 run 预算:额度用尽后不再调用,审计写明原因", async () => {
290
+ const { events, calls, service } = setup({ summarizeMaxRunsPerSession: 1, distillRateLimitIntervalMs: 0 });
291
+ const handler = events.find((e) => e.name === "session/event").fn;
292
+ const session = {
293
+ id: "s239-cap",
294
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
295
+ events: [userMessage("第一轮", 1), { seq: 2, type: "turn/end" }]
296
+ };
297
+ await handler(session, { seq: 2, type: "turn/end" });
298
+ assert.equal(calls.length, 1, "第一次在预算内");
299
+
300
+ session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
301
+ await handler(session, { seq: 4, type: "turn/end" });
302
+ assert.equal(calls.length, 1, "预算用尽后不得再调用");
303
+ const rows = service.listLlmAudits();
304
+ assert.equal(rows[0].status, "skipped");
305
+ assert.equal(rows[0].error_message, "max-runs-per-session");
306
+ });
307
+
308
+ test("#239 预算只计真实调用:被预判拦下的窗口不消耗额度", async () => {
309
+ const { events, calls } = setup({ summarizeMaxRunsPerSession: 1, summarizeMinWindowChars: 10, distillRateLimitIntervalMs: 0 });
310
+ const handler = events.find((e) => e.name === "session/event").fn;
311
+ const session = {
312
+ id: "s239-budget",
313
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
314
+ events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
315
+ };
316
+ await handler(session, { seq: 2, type: "turn/end" });
317
+ assert.equal(calls.length, 0, "第一轮被零 LLM 预判拦下");
318
+
319
+ session.events.push(userMessage("第二轮写得足够长,应该放行并消耗唯一的一次额度", 3), { seq: 4, type: "turn/end" });
320
+ await handler(session, { seq: 4, type: "turn/end" });
321
+ assert.equal(calls.length, 1, "零成本窗口不该吃掉预算");
322
+ });
323
+
324
+ // --- Issue #239 第 4 项:错峰队列 -------------------------------------------------
325
+
326
+ test("#239-4 高峰时段解析:合法 / 多段 / 跨零点 / 星期前缀 / 非法一律按未配置", () => {
327
+ assert.equal(parsePeakSpec(""), null);
328
+ assert.equal(parsePeakSpec(" "), null);
329
+ assert.deepEqual(parsePeakSpec("09:00-18:00"), { days: null, windows: [{ start: 540, end: 1080 }] });
330
+ assert.deepEqual(parsePeakSpec("09:00-12:00, 14:00-18:00"), {
331
+ days: null,
332
+ windows: [{ start: 540, end: 720 }, { start: 840, end: 1080 }]
333
+ });
334
+ assert.deepEqual(parsePeakSpec("23:00-06:00"), { days: null, windows: [{ start: 1380, end: 360 }] });
335
+ // 星期前缀:ISO 1=周一…7=周日,也认 mon..sun;支持列表与跨周环绕
336
+ assert.deepEqual(parsePeakSpec("mon-fri 08:00-12:00,14:00-18:00"), {
337
+ days: [1, 2, 3, 4, 5],
338
+ windows: [{ start: 480, end: 720 }, { start: 840, end: 1080 }]
339
+ });
340
+ assert.deepEqual(parsePeakSpec("1-5 08:00-12:00").days, [1, 2, 3, 4, 5]);
341
+ assert.deepEqual(parsePeakSpec("sat,sun 10:00-12:00").days, [6, 7]);
342
+ assert.deepEqual(parsePeakSpec("fri-mon 10:00-12:00").days, [1, 5, 6, 7], "跨周环绕");
343
+ // 排程是省钱手段,绝不该因为写错格式把蒸馏停掉:任何非法写法都当作「关闭」。
344
+ for (const bad of ["09:00~18:00", "24:00-06:00", "09:60-18:00", "09:00-09:00", "abc", "09:00-18:00,oops", "weekday 09:00-18:00", "8 09:00-18:00"]) {
345
+ assert.equal(parsePeakSpec(bad), null, `${bad} 应视为未配置`);
346
+ }
347
+ });
348
+
349
+ test("#239-4 高峰判定与补跑时刻(跨零点、右开区间)", () => {
350
+ const at = (h, m) => new Date(2026, 8, 19, h, m, 30);
351
+ assert.equal(isInPeakWindow(at(10, 0), "09:00-18:00"), true);
352
+ assert.equal(isInPeakWindow(at(18, 0), "09:00-18:00"), false, "右开区间:18:00 已出高峰");
353
+ assert.equal(isInPeakWindow(at(3, 0), "23:00-06:00"), true, "跨零点时段");
354
+ assert.equal(isInPeakWindow(at(12, 0), "23:00-06:00"), false);
355
+ assert.equal(isInPeakWindow(at(10, 0), ""), false, "未配置 = 永不高峰");
356
+ const next = nextOffPeakAt(at(10, 30), "09:00-18:00");
357
+ assert.equal(next.getHours(), 18);
358
+ assert.equal(next.getMinutes(), 0);
359
+ assert.equal(nextOffPeakAt(at(3, 0), "23:00-06:00").getHours(), 6, "跨零点取次日 06:00 结束点");
360
+ assert.equal(nextOffPeakAt(at(20, 0), "09:00-18:00"), null, "非高峰时刻无需补跑");
361
+ });
362
+
363
+ // 夹具:按 ISO 星期几(1=周一…7=周日)从当月 1 号推算出日期,避免把星期写死在测试里。
364
+ function dateOnIsoDay(isoDay, hour, minute) {
365
+ const first = new Date(2026, 8, 1, hour, minute, 0);
366
+ const firstIso = first.getDay() === 0 ? 7 : first.getDay();
367
+ return new Date(2026, 8, 1 + ((isoDay - firstIso + 7) % 7), hour, minute, 0);
368
+ }
369
+
370
+ test("#239-4 星期过滤:工作日高峰不误伤周末(跨零点段按「开窗那天」认星期)", () => {
371
+ const spec = "mon-fri 08:00-12:00,14:00-18:00";
372
+ assert.equal(dateOnIsoDay(1, 9, 0).getDay(), 1, "夹具自检:周一");
373
+ assert.equal(dateOnIsoDay(6, 9, 0).getDay(), 6, "夹具自检:周六");
374
+ assert.equal(isInPeakWindow(dateOnIsoDay(1, 9, 0), spec), true, "工作日 09:00 在高峰");
375
+ assert.equal(isInPeakWindow(dateOnIsoDay(1, 12, 30), spec), false, "工作日午休不在高峰");
376
+ assert.equal(isInPeakWindow(dateOnIsoDay(6, 9, 0), spec), false, "周六同刻不在高峰(按周计费)");
377
+ assert.equal(isInPeakWindow(dateOnIsoDay(7, 15, 0), spec), false, "周日下午不在高峰");
378
+ assert.equal(nextOffPeakAt(dateOnIsoDay(1, 9, 0), spec).getHours(), 12, "工作日顺延到 12:00");
379
+ assert.equal(nextOffPeakAt(dateOnIsoDay(6, 9, 0), spec), null, "周末无需顺延");
380
+ // 跨零点段:周五 23:00 开的窗口延续到周六凌晨,仍算高峰;周六开的则不覆盖周日
381
+ const overnight = "mon-fri 23:00-06:00";
382
+ assert.equal(isInPeakWindow(dateOnIsoDay(6, 2, 0), overnight), true, "周六 02:00 属周五开的窗口");
383
+ assert.equal(isInPeakWindow(dateOnIsoDay(7, 2, 0), overnight), false, "周日 02:00 属周六开的窗口(周六不在集合内)");
384
+ });
385
+
386
+ test("#239-4 默认关:未配置高峰时行为与现状一致", async () => {
387
+ const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
388
+ const { events, calls } = setup({}, { deps: clock.deps });
389
+ const handler = events.find((e) => e.name === "session/event").fn;
390
+ const session = {
391
+ id: "s239-peak-off",
392
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
393
+ events: [userMessage("普通一轮", 1), { seq: 2, type: "turn/end" }]
394
+ };
395
+ await handler(session, { seq: 2, type: "turn/end" });
396
+ assert.equal(calls.length, 1, "未配置高峰 → 照常蒸馏");
397
+ assert.equal(clock.pending(), 0, "不该挂补跑定时器");
398
+ });
399
+
400
+ test("#239-4 高峰内不调模型:skip 审计 + 不消费游标,非高峰补跑整窗", async () => {
401
+ const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
402
+ const { events, calls, service, store } = setup(
403
+ { summarizePeakHours: "09:00-18:00", distillRateLimitIntervalMs: 0 },
404
+ { deps: clock.deps }
405
+ );
406
+ const handler = events.find((e) => e.name === "session/event").fn;
407
+ const session = {
408
+ id: "s239-peak",
409
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
410
+ events: [userMessage("高峰里的第一段", 1), { seq: 2, type: "turn/end" }]
411
+ };
412
+ await handler(session, { seq: 2, type: "turn/end" });
413
+ assert.equal(calls.length, 0, "高峰期不得调模型");
414
+ assert.equal(store.count(), 0, "不该写入记忆");
415
+ const rows = service.listLlmAudits();
416
+ assert.equal(rows[0].status, "skipped");
417
+ assert.equal(rows[0].error_message, "peak-hours", "skip 原因必须可观测");
418
+ assert.equal(clock.pending(), 1, "应挂一个补跑定时器");
419
+
420
+ // 高峰期间又来一轮:游标未消费 → 窗口累积;仍不调模型、定时器不叠加
421
+ session.events.push(userMessage("高峰里的第二段", 3), { seq: 4, type: "turn/end" });
422
+ await handler(session, { seq: 4, type: "turn/end" });
423
+ assert.equal(calls.length, 0);
424
+ assert.equal(clock.pending(), 1, "每会话只挂一个定时器");
425
+
426
+ // 推到非高峰:一次补跑,且蒸到的是累积后的整窗
427
+ await clock.advanceTo(new Date(2026, 8, 19, 18, 0, 5));
428
+ assert.equal(calls.length, 1, "非高峰补跑一次");
429
+ const prompt = JSON.stringify(calls[0].messages);
430
+ assert.ok(prompt.includes("高峰里的第一段") && prompt.includes("高峰里的第二段"),
431
+ "补跑应蒸馏累积后的完整窗口");
432
+ assert.ok(store.count() > 0, "补跑真的写入了记忆");
433
+ });
434
+
435
+ test("#239-4 顺延上限:长高峰到点照跑,不饿死蒸馏", async () => {
436
+ const clock = fakeClock(new Date(2026, 8, 19, 9, 0, 0));
437
+ const { events, calls } = setup(
438
+ { summarizePeakHours: "09:00-18:00", summarizePeakMaxDeferMinutes: 30, distillRateLimitIntervalMs: 0 },
439
+ { deps: clock.deps }
440
+ );
441
+ const handler = events.find((e) => e.name === "session/event").fn;
442
+ const session = {
443
+ id: "s239-peak-cap",
444
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
445
+ events: [userMessage("长高峰里的一轮", 1), { seq: 2, type: "turn/end" }]
446
+ };
447
+ await handler(session, { seq: 2, type: "turn/end" });
448
+ assert.equal(calls.length, 0, "高峰内先顺延");
449
+ await clock.advance(31 * 60000);
450
+ assert.equal(calls.length, 1, "上限到点仍处高峰也照跑(bypassPeak)");
451
+ });
452
+
453
+ test("does not call the LLM when no event was added after the last successful seq", async () => {
454
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
455
+ const handler = events.find((e) => e.name === "session/event").fn;
456
+ const transcript = [userMessage("首轮", 1), { seq: 2, type: "turn/end" }];
457
+ const ranges = [];
458
+ const session = {
459
+ id: "s-noop",
460
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
461
+ snapshotEvents(fromSeq, toSeq) {
462
+ ranges.push([fromSeq, toSeq]);
463
+ return transcript;
464
+ }
465
+ };
466
+
467
+ await handler(session, { seq: 2, type: "turn/end" });
468
+ await handler(session, { seq: 2, type: "turn/end" });
469
+
470
+ assert.equal(calls.length, 1, "a successful window must not be distilled twice");
471
+ assert.equal(ranges[1][0], 2, "the successful event seq is passed as the next snapshot lower bound");
472
+ });
473
+
474
+ test("resumes a persisted seq cursor after the summarizer is restarted", async () => {
475
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-distill-cursor-"));
476
+ const dbPath = join(dir, "memory.db");
477
+ let firstStore;
478
+ let firstSummarizer;
479
+ let secondStore;
480
+ let secondSummarizer;
481
+ try {
482
+ firstStore = createStore(dbPath);
483
+ const first = setup({ distillRateLimitIntervalMs: 0 }, { store: firstStore });
484
+ firstSummarizer = first.summarizer;
485
+ const session = {
486
+ id: "s-restart",
487
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
488
+ events: [userMessage("重启前的窗口", 1), { seq: 2, type: "turn/end" }]
489
+ };
490
+ const firstHandler = first.events.find((e) => e.name === "session/event").fn;
491
+ await firstHandler(session, { seq: 2, type: "turn/end" });
492
+ assert.equal(first.calls.length, 1);
493
+ assert.equal(firstStore.getDistillCursor("s-restart").last_seq, 2);
494
+
495
+ firstSummarizer.dispose();
496
+ firstStore.close();
497
+ firstSummarizer = undefined;
498
+ firstStore = undefined;
499
+
500
+ secondStore = createStore(dbPath);
501
+ const second = setup({ distillRateLimitIntervalMs: 0 }, { store: secondStore });
502
+ secondSummarizer = second.summarizer;
503
+ const secondHandler = second.events.find((e) => e.name === "session/event").fn;
504
+ await secondHandler(session, { seq: 2, type: "turn/end" });
505
+
506
+ assert.equal(second.calls.length, 0, "a restarted summarizer must reuse the persisted cursor");
507
+ assert.equal(secondStore.count(), 2, "the restart must not duplicate memories");
508
+ assert.equal(secondStore.getDistillCursor("s-restart").last_seq, 2);
509
+ } finally {
510
+ secondSummarizer?.dispose();
511
+ secondStore?.close();
512
+ firstSummarizer?.dispose();
513
+ firstStore?.close();
514
+ rmSync(dir, { recursive: true, force: true });
515
+ }
516
+ });
517
+
518
+ test("treats a valid empty summary as a successful cursor commit", async () => {
519
+ const { events, store, calls } = setup(
520
+ { distillRateLimitIntervalMs: 0 },
521
+ { stream: streamOf([]) }
522
+ );
523
+ const handler = events.find((e) => e.name === "session/event").fn;
524
+ const session = {
525
+ id: "s-empty-summary",
526
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
527
+ events: [userMessage("这一轮没有可保存的长期记忆", 1), { seq: 2, type: "turn/end" }]
528
+ };
529
+
530
+ await handler(session, { seq: 2, type: "turn/end" });
531
+ assert.equal(calls.length, 1);
532
+ assert.equal(store.count(), 0);
533
+ assert.equal(store.getDistillCursor(session.id).last_seq, 2);
534
+
535
+ await handler(session, { seq: 2, type: "turn/end" });
536
+ assert.equal(calls.length, 1, "a valid empty summary must consume the window once");
537
+ });
538
+
539
+ test("advances the cursor to the window end for mixed valid and invalid entries", async () => {
540
+ const valid = { type: "history", title: "有效摘要", content: "只保存这一条", importance: 3 };
541
+ const { events, store, calls } = setup(
542
+ { distillRateLimitIntervalMs: 0 },
543
+ { stream: streamOf([valid, { type: "unknown", title: "无效摘要", content: "忽略" }, null]) }
544
+ );
545
+ const handler = events.find((e) => e.name === "session/event").fn;
546
+ const session = {
547
+ id: "s-mixed-summary",
548
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
549
+ events: [userMessage("混合摘要窗口", 1), { seq: 2, type: "assistant/message" }, { seq: 3, type: "turn/end" }]
550
+ };
551
+
552
+ await handler(session, { seq: 3, type: "turn/end" });
553
+ assert.equal(calls.length, 1);
554
+ assert.equal(store.count(), 1);
555
+ assert.equal(store.all()[0].title, valid.title);
556
+ assert.equal(store.getDistillCursor(session.id).last_seq, 3);
557
+
558
+ await handler(session, { seq: 3, type: "turn/end" });
559
+ assert.equal(calls.length, 1, "a mixed summary must consume the window once");
560
+ });
561
+
562
+ test("distills only new seq events and keeps the recent tail when a window exceeds distillMaxChars", async () => {
563
+ const { events, calls } = setup(
564
+ { distillMaxChars: 80, distillRateLimitIntervalMs: 0 },
565
+ { stream: streamOf([]) }
566
+ );
567
+ const handler = events.find((e) => e.name === "session/event").fn;
568
+ const transcript = [
569
+ userMessage(`OLD_BEGINNING ${"x".repeat(160)} OLD_RECENT`, 1),
570
+ { seq: 2, type: "assistant/message", data: { message: { content: [{ type: "text", text: "旧回复" }] } } },
571
+ { seq: 3, type: "turn/end" }
572
+ ];
573
+ const session = {
574
+ id: "s-incremental",
575
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
576
+ snapshotEvents: () => transcript
577
+ };
578
+
579
+ await handler(session, { seq: 3, type: "turn/end" });
580
+ const firstInput = calls[0].messages.find((message) => message.role === "user").content[0].text;
581
+
582
+ transcript.push(
583
+ userMessage(`NEW_BEGINNING ${"y".repeat(160)} NEW_RECENT`, 4),
584
+ { seq: 5, type: "assistant/message", data: { message: { content: [{ type: "text", text: "新回复" }] } } },
585
+ { seq: 6, type: "turn/end" }
586
+ );
587
+ await handler(session, { seq: 6, type: "turn/end" });
588
+ const secondInput = calls[1].messages.find((message) => message.role === "user").content[0].text;
589
+
590
+ assert.notEqual(firstInput, secondInput, "successive distills must receive different windows");
591
+ assert.ok(secondInput.includes("NEW_RECENT"), "the newest event content remains in the bounded window");
592
+ assert.ok(!secondInput.includes("OLD_BEGINNING"), "the second window must not restart at the session beginning");
593
+ assert.ok(!secondInput.includes("OLD_RECENT"), "the previous successful window must not be repeated");
594
+ });
595
+
596
+ test("dispose unsubscribes and stops later turn/end events from summarizing", async () => {
597
+ const { events, summarizer, calls } = setup();
598
+ const handler = events.find((e) => e.name === "session/event").fn;
599
+ summarizer.dispose();
600
+ // The ctx.on() disposer must have removed the listener.
601
+ assert.ok(!events.some((e) => e.name === "session/event"));
602
+ const session = {
603
+ id: "s1",
604
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
605
+ events: [userMessage("你好"), { seq: 2, type: "turn/end" }]
606
+ };
607
+ // Even a stale handler reference must not start a new LLM call.
608
+ await handler(session, { seq: 2, type: "turn/end" });
609
+ assert.equal(calls.length, 0);
610
+ });
611
+
612
+ test("excludes plugin-injected user/message events from summarization input", async () => {
613
+ const { events, store, calls } = setup();
614
+ const handler = events.find((e) => e.name === "session/event").fn;
615
+ const session = {
616
+ id: "s2",
617
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
618
+ events: [
619
+ {
620
+ seq: 1,
621
+ type: "user/message",
622
+ data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 内容" }] }
623
+ },
624
+ userMessage("帮我看看这个报错"),
625
+ { seq: 3, type: "turn/end" }
626
+ ]
627
+ };
628
+ await handler(session, { seq: 3, type: "turn/end" });
629
+ assert.equal(calls.length, 1);
630
+ const userMessages = calls[0].messages.filter((m) => m.role === "user");
631
+ assert.equal(userMessages.length, 1);
632
+ assert.ok(!JSON.stringify(calls[0].messages).includes("AGENTS.md"));
633
+ assert.equal(store.count(), 2);
634
+ });
635
+
636
+ test("aborted finish does not store entries and leaves the seq window retryable", async () => {
637
+ let attempt = 0;
638
+ const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
639
+ stream() {
640
+ attempt++;
641
+ return (async function* () {
642
+ yield { type: "block-start", block: { type: "text" } };
643
+ if (attempt === 1) {
644
+ yield { type: "text-delta", delta: "[]" };
645
+ yield { type: "finish", kind: "aborted" };
646
+ return;
647
+ }
648
+ yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试", content: "保留原窗口", importance: 3 }]) };
649
+ yield { type: "finish", kind: "ok" };
650
+ })();
651
+ }
652
+ });
653
+ const handler = events.find((e) => e.name === "session/event").fn;
654
+ const session = {
655
+ id: "s3",
656
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
657
+ events: [userMessage("继续", 1), { seq: 2, type: "turn/end" }]
658
+ };
659
+ await handler(session, { seq: 2, type: "turn/end" });
660
+ assert.equal(calls.length, 1); // the stream was actually reached
661
+ assert.equal(store.count(), 0);
662
+ await handler(session, { seq: 2, type: "turn/end" });
663
+ assert.equal(calls.length, 2, "an aborted window must be retried instead of being marked consumed");
664
+ assert.equal(store.count(), 1);
665
+ });
666
+
667
+ test("a stream failure leaves the seq window retryable", async () => {
668
+ let attempt = 0;
669
+ const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
670
+ stream() {
671
+ attempt++;
672
+ if (attempt === 1) {
673
+ return (async function* () {
674
+ throw new Error("temporary stream failure");
675
+ })();
676
+ }
677
+ return streamOf([{ type: "history", title: "重试成功", content: "失败窗口未丢失", importance: 3 }])();
678
+ }
679
+ });
680
+ const handler = events.find((e) => e.name === "session/event").fn;
681
+ const session = {
682
+ id: "s-stream-failure",
683
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
684
+ events: [userMessage("保留失败窗口", 1), { seq: 2, type: "turn/end" }]
685
+ };
686
+
687
+ await handler(session, { seq: 2, type: "turn/end" });
688
+ assert.equal(calls.length, 1);
689
+ assert.equal(store.count(), 0);
690
+ await handler(session, { seq: 2, type: "turn/end" });
691
+ assert.equal(calls.length, 2, "a failed stream must be retried even without new events");
692
+ assert.equal(store.count(), 1);
693
+ });
694
+
695
+ test("invalid summary JSON leaves the seq window retryable", async () => {
696
+ let attempt = 0;
697
+ const { events, store, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
698
+ stream() {
699
+ attempt++;
700
+ const output = attempt === 1 ? "not a JSON array" : "[]";
701
+ return (async function* () {
702
+ yield { type: "block-start", block: { type: "text" } };
703
+ yield { type: "text-delta", delta: output };
704
+ yield { type: "finish", kind: "ok" };
705
+ })();
706
+ }
707
+ });
708
+ const handler = events.find((e) => e.name === "session/event").fn;
709
+ const session = {
710
+ id: "s-parse-failure",
711
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
712
+ events: [userMessage("保留解析窗口", 1), { seq: 2, type: "turn/end" }]
713
+ };
714
+
715
+ await handler(session, { seq: 2, type: "turn/end" });
716
+ await handler(session, { seq: 2, type: "turn/end" });
717
+ assert.equal(calls.length, 2, "a parse failure must not advance the seq cursor");
718
+ assert.equal(store.count(), 0, "the valid empty retry remains a no-op");
719
+ });
720
+
721
+ test("a JSON-crashed window with salvageable objects is recovered, consumed once, and audited (issue #339)", async (t) => {
722
+ const malformed = `[
723
+ {"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
724
+ { " "type": "history", "title": "坏对象", "content": "语法错误" },
725
+ {"type":"preference","title":"硬件","content":"只要 brushed brass"}
726
+ ]`;
727
+ const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
728
+ stream() {
729
+ return (async function* () {
730
+ yield { type: "block-start", block: { type: "text" } };
731
+ yield { type: "text-delta", delta: malformed };
732
+ yield { type: "finish", kind: "ok" };
733
+ })();
734
+ }
735
+ });
736
+ t.after(() => { summarizer.dispose(); store.close(); });
737
+ const handler = events.find((e) => e.name === "session/event").fn;
738
+ const session = {
739
+ id: "s-salvage",
740
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
741
+ events: [userMessage("带语法错误的蒸馏窗口", 1), { seq: 2, type: "turn/end" }]
742
+ };
743
+
744
+ await handler(session, { seq: 2, type: "turn/end" });
745
+ assert.equal(service.count(), 2, "the two intact objects are written, the broken one dropped");
746
+ const titles = service.list().map((m) => m.title).sort();
747
+ assert.deepEqual(titles, ["硬件", "预算"]);
748
+ assert.equal(calls.length, 1, "salvage consumed the window: no same-window retry");
749
+ const audit = service.listLlmAudits().find((r) => r.status === "success");
750
+ assert.ok(audit, "salvaged run is audited as a success");
751
+ assert.equal(audit.metadata?.json_salvaged, true, "salvage is observable in the audit trail");
752
+
753
+ await handler(session, { seq: 2, type: "turn/end" });
754
+ assert.equal(calls.length, 1, "the consumed window is not re-distilled");
755
+ assert.equal(service.count(), 2, "no duplicate writes on the second turn/end");
756
+ });
757
+
758
+ test("a JSON-crashed window with nothing salvageable stays retryable (issue #339)", async (t) => {
759
+ const hopeless = `[
760
+ { " "type": "history", "title": "坏", "content": "语法错误" }
761
+ ]`;
762
+ let attempt = 0;
763
+ const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
764
+ stream() {
765
+ const output = attempt++ === 0 ? hopeless : `[
766
+ {"type":"history","title":"重试成功","content":"干净窗口","importance":3}
767
+ ]`;
768
+ return (async function* () {
769
+ yield { type: "block-start", block: { type: "text" } };
770
+ yield { type: "text-delta", delta: output };
771
+ yield { type: "finish", kind: "ok" };
772
+ })();
773
+ }
774
+ });
775
+ t.after(() => { summarizer.dispose(); store.close(); });
776
+ const handler = events.find((e) => e.name === "session/event").fn;
777
+ const session = {
778
+ id: "s-salvage-none",
779
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
780
+ events: [userMessage("救不回的窗口", 1), { seq: 2, type: "turn/end" }]
781
+ };
782
+
783
+ await handler(session, { seq: 2, type: "turn/end" });
784
+ assert.equal(service.count(), 0, "zero salvage is not a fake success");
785
+ await handler(session, { seq: 2, type: "turn/end" });
786
+ assert.equal(calls.length, 2, "the window stays retryable");
787
+ assert.equal(service.count(), 1);
788
+ assert.ok(service.listLlmAudits().some(
789
+ (r) => r.status === "error" && r.error_message === "invalid summary JSON"
790
+ ), "zero-salvage run keeps the error audit");
791
+ });
792
+
793
+ test("an all-invalid summary leaves the same window retryable until valid memories are saved", async (t) => {
794
+ for (const invalid of [
795
+ [{}],
796
+ [{ type: "unknown", title: "无效类型", content: "不能消费窗口" }],
797
+ [{ type: "history", title: " ", content: "不能消费窗口" }, null, "garbage"]
798
+ ]) {
799
+ await t.test(JSON.stringify(invalid), async (t) => {
800
+ let attempt = 0;
801
+ const valid = { type: "history", title: "重试成功", content: "全无效摘要后仍保留原窗口", importance: 3 };
802
+ const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
803
+ stream() {
804
+ return streamOf(attempt++ === 0 ? invalid : [valid])();
805
+ }
806
+ });
807
+ t.after(() => { summarizer.dispose(); store.close(); });
808
+ const handler = events.find((e) => e.name === "session/event").fn;
809
+ const session = {
810
+ id: "s-all-invalid",
811
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
812
+ events: [userMessage("保留原窗口", 1), { seq: 2, type: "turn/end" }]
813
+ };
814
+
815
+ await handler(session, { seq: 2, type: "turn/end" });
816
+ assert.equal(service.count(), 0);
817
+ await handler(session, { seq: 2, type: "turn/end" });
818
+ assert.equal(calls.length, 2, "all-invalid entries must not consume the window");
819
+ const firstTranscript = calls[0].messages.find((message) => message.role === "user").content[0].text;
820
+ const retryTranscript = calls[1].messages.find((message) => message.role === "user").content[0].text;
821
+ assert.equal(retryTranscript, firstTranscript, "retry receives the original transcript");
822
+ assert.equal(service.count(), 1);
823
+ assert.equal(service.list()[0].content, valid.content);
824
+ assert.ok(service.listLlmAudits({ source: "autoSummarize" }).some(
825
+ (audit) => audit.status === "error" && audit.error_message === "invalid summary JSON"
826
+ ), "an all-invalid summary must be audited as an error");
827
+
828
+ await handler(session, { seq: 2, type: "turn/end" });
829
+ assert.equal(calls.length, 2, "the successfully retried window is consumed once");
830
+ assert.equal(service.count(), 1);
831
+ });
832
+ }
833
+ });
834
+
835
+ test("rolls back partial memory writes so retrying a failed window does not duplicate entries", async () => {
836
+ const { events, store, service, calls } = setup(
837
+ { distillRateLimitIntervalMs: 0 },
838
+ {
839
+ stream: streamOf([
840
+ { type: "history", title: "第一条", content: "第一条内容", importance: 3 },
841
+ { type: "decision", title: "第二条", content: "第二条内容", importance: 4 }
842
+ ])
843
+ }
844
+ );
845
+ const originalSave = service.saveWithDedupe.bind(service);
846
+ let failOnSecondEntry = true;
847
+ service.saveWithDedupe = (memory) => {
848
+ const result = originalSave(memory);
849
+ if (failOnSecondEntry && memory.title === "第二条") {
850
+ failOnSecondEntry = false;
851
+ throw new Error("simulated memory write failure");
852
+ }
853
+ return result;
854
+ };
855
+ const handler = events.find((e) => e.name === "session/event").fn;
856
+ const session = {
857
+ id: "s-partial-write",
858
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
859
+ events: [userMessage("不要丢失这个窗口", 1), { seq: 2, type: "turn/end" }]
860
+ };
861
+
862
+ await handler(session, { seq: 2, type: "turn/end" });
863
+ assert.equal(calls.length, 1);
864
+ assert.equal(store.count(), 0, "a failed write transaction must leave no partial memory");
865
+ assert.equal(store.getDistillCursor("s-partial-write"), undefined, "a failed write must not advance the cursor");
866
+
867
+ await handler(session, { seq: 2, type: "turn/end" });
868
+ assert.equal(calls.length, 2, "the failed seq window must be retried");
869
+ assert.equal(store.count(), 2);
870
+ assert.equal(store.getDistillCursor("s-partial-write").last_seq, 2);
871
+ assert.equal(store.all().find((memory) => memory.title === "第一条")?.content, "第一条内容");
872
+
873
+ await handler(session, { seq: 2, type: "turn/end" });
874
+ assert.equal(calls.length, 2, "a successfully retried window must not be distilled again");
875
+ assert.equal(store.count(), 2);
876
+ });
877
+
878
+ test("rolls back memory writes when persisting the cursor fails", async () => {
879
+ const { events, store, service, calls } = setup(
880
+ { distillRateLimitIntervalMs: 0 },
881
+ { stream: streamOf([{ type: "history", title: "游标失败", content: "事务应回滚", importance: 3 }]) }
882
+ );
883
+ const originalSetCursor = service.setDistillCursor.bind(service);
884
+ let failOnce = true;
885
+ service.setDistillCursor = (sessionId, lastSeq) => {
886
+ if (failOnce) {
887
+ failOnce = false;
888
+ throw new Error("simulated cursor write failure");
889
+ }
890
+ return originalSetCursor(sessionId, lastSeq);
891
+ };
892
+ const handler = events.find((e) => e.name === "session/event").fn;
893
+ const session = {
894
+ id: "s-cursor-write-failure",
895
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
896
+ events: [userMessage("游标写入失败也不能丢窗口", 1), { seq: 2, type: "turn/end" }]
897
+ };
898
+
899
+ await handler(session, { seq: 2, type: "turn/end" });
900
+ assert.equal(calls.length, 1);
901
+ assert.equal(store.count(), 0, "cursor failure must roll back the memory write");
902
+ assert.equal(store.getDistillCursor(session.id), undefined);
903
+ const failedAudit = service.listLlmAudits({ source: "autoSummarize" }).find(
904
+ (audit) => audit.error_message === "simulated cursor write failure"
905
+ );
906
+ assert.equal(failedAudit?.status, "error", "cursor failure must not be audited as success");
907
+
908
+ await handler(session, { seq: 2, type: "turn/end" });
909
+ assert.equal(calls.length, 2, "the failed cursor window must be retryable");
910
+ assert.equal(store.count(), 1);
911
+ assert.equal(store.getDistillCursor(session.id).last_seq, 2);
912
+ });
913
+
914
+ test("falls back to the in-memory cursor when service lacks setDistillCursor", async () => {
915
+ // 旧版 service(#274 之前)没有持久化游标:缺方法必须降级为内存游标照常蒸馏,
916
+ // 而不是抛错打断每轮蒸馏。方法存在但抛错走的是上一条回滚用例,两者不可混淆。
917
+ const { events, store, service, calls } = setup(
918
+ { distillRateLimitIntervalMs: 0 },
919
+ { stream: streamOf([{ type: "history", title: "降级游标", content: "缺方法也能蒸馏", importance: 3 }]) }
920
+ );
921
+ delete service.setDistillCursor; // 模拟旧版 service:实例属性,直接删掉
922
+ const handler = events.find((e) => e.name === "session/event").fn;
923
+ const session = {
924
+ id: "s-no-cursor-api",
925
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
926
+ events: [userMessage("旧宿主没有游标接口", 1), { seq: 2, type: "turn/end" }]
927
+ };
928
+
929
+ await handler(session, { seq: 2, type: "turn/end" });
930
+ assert.equal(calls.length, 1, "distillation must still run without a persisted cursor");
931
+ assert.equal(store.count(), 1, "the memory itself must be saved");
932
+ assert.equal(store.getDistillCursor(session.id), undefined, "nothing may be persisted without the API");
933
+
934
+ await handler(session, { seq: 2, type: "turn/end" });
935
+ assert.equal(calls.length, 1, "the in-memory cursor must prevent re-distillation in this process");
936
+ });
937
+
938
+ test("uses summarizeProvider/summarizeModel config override when set", async () => {
939
+ const { events, calls } = setup({
940
+ summarizeProvider: "aliyun",
941
+ summarizeModel: "qwen3.6-plus"
942
+ });
943
+ const handler = events.find((e) => e.name === "session/event").fn;
944
+ const session = {
945
+ id: "s4",
946
+ // Session header has a different model — config override should win.
947
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-v4-pro" } }),
948
+ events: [userMessage("测试覆盖"), { seq: 2, type: "turn/end" }]
949
+ };
950
+ await handler(session, { seq: 2, type: "turn/end" });
951
+ assert.equal(calls.length, 1);
952
+ assert.equal(calls[0].provider, "aliyun");
953
+ assert.equal(calls[0].model, "qwen3.6-plus");
954
+ });
955
+
956
+ test("falls back to session header when summarize config is empty", async () => {
957
+ const { events, calls } = setup({
958
+ summarizeProvider: "",
959
+ summarizeModel: ""
960
+ });
961
+ const handler = events.find((e) => e.name === "session/event").fn;
962
+ const session = {
963
+ id: "s5",
964
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
965
+ events: [userMessage("回退测试"), { seq: 2, type: "turn/end" }]
966
+ };
967
+ await handler(session, { seq: 2, type: "turn/end" });
968
+ assert.equal(calls.length, 1);
969
+ assert.equal(calls[0].provider, "deepseek");
970
+ assert.equal(calls[0].model, "deepseek-chat");
971
+ });
972
+
973
+ // ── v0.7.11:智能调速器(429 保护)+ 完整转录/原子记忆 ─────────────────────
974
+
975
+ test("serializes distill LLM calls across sessions (global queue, no concurrency)", async () => {
976
+ const timeline = [];
977
+ const { events, store } = setup({ distillRateLimitIntervalMs: 0 }, {
978
+ stream() {
979
+ return (async function* () {
980
+ timeline.push(`start:${Date.now()}`);
981
+ await new Promise((r) => setTimeout(r, 8));
982
+ yield { type: "block-start", block: { type: "text" } };
983
+ yield { type: "text-delta", delta: "[]" };
984
+ yield { type: "finish", kind: "ok" };
985
+ timeline.push(`end:${Date.now()}`);
986
+ })();
987
+ }
988
+ });
989
+ const handler = events.find((e) => e.name === "session/event").fn;
990
+ const mkSession = (id) => ({
991
+ id,
992
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
993
+ events: [userMessage(`问题${id}`), { seq: 2, type: "turn/end" }]
994
+ });
995
+ // 两个会话几乎同时 turn/end → 必须排队,第二个请求不能与第一个并发。
996
+ await Promise.all([
997
+ handler(mkSession("a"), { seq: 2, type: "turn/end" }),
998
+ handler(mkSession("b"), { seq: 2, type: "turn/end" })
999
+ ]);
1000
+ assert.equal(timeline.length, 4); // 每个流 start + end
1001
+ const starts = timeline.filter((t) => t.startsWith("start")).map((t) => Number(t.slice(6)));
1002
+ const ends = timeline.filter((t) => t.startsWith("end")).map((t) => Number(t.slice(4)));
1003
+ assert.ok(starts[1] >= ends[0], "second distill must start only after the first finished (serial queue)");
1004
+ });
1005
+
1006
+ test("retries with exponential backoff on 429 and still stores entries", async () => {
1007
+ let attempts = 0;
1008
+ const { events, store } = setup(
1009
+ { distillRateLimitRetries: 3, distillRateLimitBaseDelayMs: 5, distillRateLimitIntervalMs: 0 },
1010
+ {
1011
+ stream() {
1012
+ return (async function* () {
1013
+ attempts++;
1014
+ if (attempts < 3) throw Object.assign(new Error("rate limit exceeded"), { status: 429 });
1015
+ yield { type: "block-start", block: { type: "text" } };
1016
+ yield { type: "text-delta", delta: JSON.stringify([{ type: "history", title: "重试成功", content: "第三次请求成功", importance: 3 }]) };
1017
+ yield { type: "finish", kind: "ok" };
1018
+ })();
1019
+ }
1020
+ }
1021
+ );
1022
+ const handler = events.find((e) => e.name === "session/event").fn;
1023
+ const session = {
1024
+ id: "s9",
1025
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1026
+ events: [userMessage("限流测试"), { seq: 2, type: "turn/end" }]
1027
+ };
1028
+ const startedAt = Date.now();
1029
+ await handler(session, { seq: 2, type: "turn/end" });
1030
+ // 429 两次 → 退避重试(5ms + 10ms),第三次成功入库。
1031
+ assert.equal(attempts, 3);
1032
+ assert.ok(Date.now() - startedAt >= 15, "backoff waits should be visible");
1033
+ assert.equal(store.count(), 1);
1034
+ assert.equal(store.all()[0].title, "重试成功");
1035
+ });
1036
+
1037
+ test("distills full transcript (tool calls, results, code output) with atomic-memory prompt", async () => {
1038
+ const { events, calls } = setup();
1039
+ const handler = events.find((e) => e.name === "session/event").fn;
1040
+ const session = {
1041
+ id: "s10",
1042
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1043
+ events: [
1044
+ userMessage("帮我修这个 bug"),
1045
+ { seq: 2, type: "tool/call", data: { name: "Bash", arguments: "node test.js" } },
1046
+ toolResultEvent("TypeError: x is not a function", true, 3),
1047
+ codeDispatchEvent("fixed", false, 4),
1048
+ { seq: 5, type: "turn/end" }
1049
+ ]
1050
+ };
1051
+ await handler(session, { seq: 5, type: "turn/end" });
1052
+ const transcript = JSON.stringify(calls[0].messages);
1053
+ assert.ok(transcript.includes("修这个 bug"));
1054
+ assert.ok(transcript.includes("TypeError: x is not a function"));
1055
+ assert.ok(transcript.includes("代码执行"));
1056
+ // 原子记忆 prompt:不再"硬压 2-3 条",而是按需多提、贴近原始细节。
1057
+ assert.ok(calls[0].messages[0].content[0].text.includes("原子记忆"));
1058
+ });
1059
+
1060
+ test("collects real tool result and code dispatch payloads with failure status", async () => {
1061
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1062
+ const handler = events.find((e) => e.name === "session/event").fn;
1063
+ const session = {
1064
+ id: "s-real-payloads",
1065
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1066
+ events: [
1067
+ userMessage("检查真实事件形状", 1),
1068
+ toolResultEvent("TypeError: tool failed", true, 2),
1069
+ codeDispatchEvent("代码执行结果", false, 3),
1070
+ { seq: 4, type: "turn/end" }
1071
+ ]
1072
+ };
1073
+
1074
+ await handler(session, { seq: 4, type: "turn/end" });
1075
+ const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1076
+ assert.match(transcript, /工具结果(失败):TypeError: tool failed/);
1077
+ assert.match(transcript, /代码执行(成功):代码执行结果/);
1078
+ });
1079
+
1080
+ test("collects tool results delivered as role:tool messages, keeping the legacy block shape", async () => {
1081
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1082
+ const handler = events.find((e) => e.name === "session/event").fn;
1083
+ const session = {
1084
+ id: "s-tool-role-shape",
1085
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1086
+ events: [
1087
+ userMessage("检查工具结果两种形状", 1),
1088
+ toolResultEvent("旧形状载荷", false, 2),
1089
+ toolRoleResultEvent("新形状载荷", false, 3),
1090
+ toolRoleResultEvent("TypeError: tool crashed", true, 4),
1091
+ { seq: 5, type: "turn/end" }
1092
+ ]
1093
+ };
1094
+
1095
+ await handler(session, { seq: 5, type: "turn/end" });
1096
+ const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1097
+ assert.match(transcript, /工具结果(成功):旧形状载荷/);
1098
+ assert.match(transcript, /工具结果(成功):新形状载荷/);
1099
+ assert.match(transcript, /工具结果(失败):TypeError: tool crashed/);
1100
+ });
1101
+
1102
+ test("keeps prefix trimming for tool results delivered as role:tool messages", async () => {
1103
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1104
+ const handler = events.find((e) => e.name === "session/event").fn;
1105
+ const session = {
1106
+ id: "s-tool-role-trim",
1107
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1108
+ events: [
1109
+ userMessage("检查新形状截断", 1),
1110
+ toolRoleResultEvent(`OUT_START ${"c".repeat(550)} OUT_END`, false, 2),
1111
+ { seq: 3, type: "turn/end" }
1112
+ ]
1113
+ };
1114
+
1115
+ await handler(session, { seq: 3, type: "turn/end" });
1116
+ const transcript = JSON.stringify(calls[0].messages);
1117
+ assert.ok(transcript.includes("OUT_START"));
1118
+ assert.ok(!transcript.includes("OUT_END"), "role:tool 结果同样走 500 字符前缀截断");
1119
+ });
1120
+
1121
+ test("collects subagent delivery once across inbox and user-message views", async () => {
1122
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1123
+ const handler = events.find((e) => e.name === "session/event").fn;
1124
+ const delivery = inboxMessage("delivery-1", "subagent-settled", "子会话 closing message");
1125
+ const agentMessage = inboxMessage("delivery-2", "agent-message", "子代理补充报告");
1126
+ const injectedInstruction = inboxMessage("instruction-1", "agent-instructions", "不要把这条注入指令沉淀为记忆");
1127
+ const session = {
1128
+ id: "s-agent-delivery",
1129
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1130
+ events: [
1131
+ userMessage("检查子会话交付", 1),
1132
+ { seq: 2, type: "agent/inbox/spliced", data: { inserted: [delivery, agentMessage, injectedInstruction] } },
1133
+ { seq: 3, type: "user/message", data: delivery },
1134
+ { seq: 4, type: "turn/end" }
1135
+ ]
1136
+ };
1137
+
1138
+ await handler(session, { seq: 4, type: "turn/end" });
1139
+ const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
1140
+ assert.equal(transcript.match(/子会话 closing message/g)?.length, 1);
1141
+ assert.equal(transcript.match(/子代理补充报告/g)?.length, 1);
1142
+ assert.ok(!transcript.includes("不要把这条注入指令沉淀为记忆"));
1143
+ });
1144
+
1145
+ test("keeps prefix trimming for tool arguments and tool results", async () => {
1146
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1147
+ const handler = events.find((e) => e.name === "session/event").fn;
1148
+ const session = {
1149
+ id: "s-trim",
1150
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1151
+ events: [
1152
+ userMessage("检查截断", 1),
1153
+ { seq: 2, type: "tool/call", data: { name: "Bash", arguments: `ARG_START ${"a".repeat(350)} ARG_END` } },
1154
+ toolResultEvent(`OUT_START ${"b".repeat(550)} OUT_END`, false, 3),
1155
+ { seq: 4, type: "turn/end" }
1156
+ ]
1157
+ };
1158
+ await handler(session, { seq: 4, type: "turn/end" });
1159
+ const transcript = JSON.stringify(calls[0].messages);
1160
+ assert.ok(transcript.includes("ARG_START"));
1161
+ assert.ok(!transcript.includes("ARG_END"), "tool arguments keep their existing prefix trim");
1162
+ assert.ok(transcript.includes("OUT_START"));
1163
+ assert.ok(!transcript.includes("OUT_END"), "tool results keep their existing prefix trim");
1164
+ });
1165
+
1166
+ test("distill excludes private assistant reasoning blocks from the transcript", async () => {
1167
+ const { events, calls } = setup();
1168
+ const handler = events.find((e) => e.name === "session/event").fn;
1169
+ const session = {
1170
+ id: "s12",
1171
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1172
+ events: [
1173
+ userMessage("解释一下这段代码"),
1174
+ {
1175
+ type: "assistant/message",
1176
+ data: {
1177
+ message: {
1178
+ content: [
1179
+ { type: "reasoning", text: "私有推理:内部权衡过程不该进记忆" },
1180
+ { type: "text", text: "这段代码有死循环,第 3 行 while 条件恒真。" }
1181
+ ]
1182
+ }
1183
+ }
1184
+ },
1185
+ { seq: 3, type: "turn/end" }
1186
+ ]
1187
+ };
1188
+ await handler(session, { seq: 3, type: "turn/end" });
1189
+ const transcript = JSON.stringify(calls[0].messages);
1190
+ assert.ok(transcript.includes("有死循环,第 3 行"), "public assistant text still distills");
1191
+ assert.ok(!transcript.includes("私有推理"), "private reasoning must never enter the distill context");
1192
+ });
1193
+
1194
+ test("codingRetrospect stores coding memory types in the coding memory type set", async () => {
1195
+ const { events, store, calls } = setup({ codingRetrospect: true }, {
1196
+ stream() {
1197
+ return (async function* () {
1198
+ yield { type: "block-start", block: { type: "text" } };
1199
+ yield { type: "text-delta", delta: JSON.stringify([
1200
+ { type: "rejected_solution", title: "弃用方案", content: "A 方案被否决,改用 B", importance: 4 }
1201
+ ]) };
1202
+ yield { type: "finish", kind: "ok" };
1203
+ })();
1204
+ }
1205
+ });
1206
+ const handler = events.find((e) => e.name === "session/event").fn;
1207
+ const session = {
1208
+ id: "s11",
1209
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1210
+ events: [userMessage("编码任务"), { seq: 2, type: "turn/end" }]
1211
+ };
1212
+ await handler(session, { seq: 2, type: "turn/end" });
1213
+ assert.equal(store.count(), 1);
1214
+ const m = store.all()[0];
1215
+ assert.equal(m.type, "rejected_solution");
1216
+ // 读取侧门控靠 m.type(rejected_solution/pitfall/constraint ∈ INJECT_TYPES),
1217
+ // sanitizeTags 不认 `type:` 前缀,所以不给编码记忆打 tag(会清空 tags 列)。
1218
+ assert.deepEqual(m.tags, []);
1219
+ // 编码模式用编码 prompt(含 rejected_solution 类型说明)。
1220
+ assert.ok(calls[0].messages[0].content[0].text.includes("rejected_solution"));
1221
+ });
1222
+
1223
+ // ── Issue #127:节流 / 条数上限 / 同会话去重 ────────────────────────────────
1224
+
1225
+ /** A distill stream that always returns exactly these entries. */
1226
+ function streamOf(entries) {
1227
+ return () => (async function* () {
1228
+ yield { type: "block-start", block: { type: "text" } };
1229
+ yield { type: "text-delta", delta: JSON.stringify(entries) };
1230
+ yield { type: "finish", kind: "ok" };
1231
+ })();
1232
+ }
1233
+
1234
+ function sessionFor(id) {
1235
+ return {
1236
+ id,
1237
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1238
+ events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
1239
+ };
1240
+ }
1241
+
1242
+ test("issue#127: min-interval gate suppresses the second turn/end and audits it as skipped", async () => {
1243
+ const { events, store, calls } = setup({ summarizeMinIntervalMinutes: 30 });
1244
+ const handler = events.find((e) => e.name === "session/event").fn;
1245
+ const session = sessionFor("t1");
1246
+ await handler(session, { seq: 2, type: "turn/end" });
1247
+ assert.equal(calls.length, 1, "the first turn/end distills");
1248
+ assert.equal(store.count(), 2);
1249
+ await handler(session, { seq: 3, type: "turn/end" });
1250
+ assert.equal(calls.length, 1, "the second turn/end inside the window makes no LLM call");
1251
+ assert.equal(store.count(), 2, "nothing new is stored");
1252
+ // 可观测性:被节流不再静默——留一行 status='skipped',一条 SQL 可自查。
1253
+ const audits = store.listLlmAudits({ source: "autoSummarize" });
1254
+ assert.ok(
1255
+ audits.some((a) => a.status === "skipped" && a.error_message === "min-interval"),
1256
+ "the suppression is visible as a status='skipped' audit row"
1257
+ );
1258
+ });
1259
+
1260
+ test("issue#127: min-interval 0 still distills every new event window", async () => {
1261
+ const { events, calls } = setup({ summarizeMinIntervalMinutes: 0 });
1262
+ const handler = events.find((e) => e.name === "session/event").fn;
1263
+ const session = sessionFor("t2");
1264
+ await handler(session, { seq: 2, type: "turn/end" });
1265
+ session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
1266
+ await handler(session, { seq: 4, type: "turn/end" });
1267
+ assert.equal(calls.length, 2, "0 = no gate (zero behavior change)");
1268
+ });
1269
+
1270
+ test("issue#127: the interval gate is per-session (one session never throttles another)", async () => {
1271
+ const { events, calls } = setup({ summarizeMinIntervalMinutes: 30 });
1272
+ const handler = events.find((e) => e.name === "session/event").fn;
1273
+ await handler(sessionFor("ta"), { seq: 2, type: "turn/end" });
1274
+ await handler(sessionFor("tb"), { seq: 2, type: "turn/end" });
1275
+ assert.equal(calls.length, 2, "a different session is not throttled by another's lastRunAt");
1276
+ });
1277
+
1278
+ test("issue#127: summarizeMaxEntriesPerRun caps the stored entries and audits parsed/capped", async () => {
1279
+ const { events, store } = setup({ summarizeMaxEntriesPerRun: 1 }, {
1280
+ stream: streamOf([
1281
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
1282
+ { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
1283
+ ])
1284
+ });
1285
+ const handler = events.find((e) => e.name === "session/event").fn;
1286
+ await handler(sessionFor("t3"), { seq: 2, type: "turn/end" });
1287
+ assert.equal(store.count(), 1, "only the first capped entry is stored");
1288
+ const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
1289
+ assert.equal(audit.metadata?.parsed, 2, "the raw parsed count is kept for tuning the cap");
1290
+ assert.equal(audit.metadata?.capped, 1);
1291
+ });
1292
+
1293
+ test("issue#127: title dedupe absorbs a normalized-same title", async () => {
1294
+ const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
1295
+ stream: streamOf([{ type: "pitfall", title: "Win7 OpenSSH 失效", content: "第二次记录", importance: 4 }])
1296
+ });
1297
+ // 既有条目:同会话、标题归一化后全等(大小写与空白差异)
1298
+ const seeded = service.saveWithDedupe({
1299
+ type: "pitfall", title: "win7 openssh 失效", content: "第一次记录", source: "session:t5"
1300
+ }).memory;
1301
+ const handler = events.find((e) => e.name === "session/event").fn;
1302
+ await handler(sessionFor("t5"), { seq: 2, type: "turn/end" });
1303
+ assert.equal(store.count(), 1, "the normalized-same title merged instead of creating a row");
1304
+ assert.ok(store.getById(seeded.id).content.includes("第二次记录"), "content appended to the existing row");
1305
+ });
1306
+
1307
+ test("issue#127: title dedupe leaves a rephrase alone (it only catches an exact same title)", async () => {
1308
+ // 同一事实的另一种措辞 → title 档不命中,正常新建。这条界定了 title 档的边界,
1309
+ // 避免被误当成 vector 档的替代(cos≥0.85 的簇里「归一化标题全同」为 0 个)。
1310
+ const { events, store, service } = setup({ summarizeDedupeMode: "title" }, {
1311
+ stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "新记录", importance: 4 }])
1312
+ });
1313
+ service.saveWithDedupe({
1314
+ type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "旧记录", source: "session:other"
1315
+ });
1316
+ const handler = events.find((e) => e.name === "session/event").fn;
1317
+ await handler(sessionFor("t7"), { seq: 2, type: "turn/end" });
1318
+ assert.equal(store.count(), 2, "a rephrase (different title) is not merged by the title tier");
1319
+ });
1320
+
1321
+ test("issue#127: vector dedupe merges a same-fact rephrase and keeps content_history", async () => {
1322
+ const embedder = {
1323
+ ready: true,
1324
+ embedSingle: async (text) => (String(text).includes("OpenSSH") ? [1, 0, 0] : [0, 1, 0])
1325
+ };
1326
+ const { events, store, service } = setup(
1327
+ { summarizeDedupeMode: "vector", summarizeDedupeMinSim: 0.92 },
1328
+ { stream: streamOf([{ type: "pitfall", title: "Win7 下 OpenSSH 官方脚本装不上", content: "重复踩坑", importance: 4 }]) }
1329
+ );
1330
+ service.setEmbedder(embedder);
1331
+ // 同一事实的另一种措辞,已有向量 → 余弦 1.0 ≥ 0.92
1332
+ const seeded = service.saveWithDedupe({
1333
+ type: "pitfall", title: "OpenSSH 官方脚本在 Win7 失效", content: "第一次记录", source: "session:t8"
1334
+ }).memory;
1335
+ store.setEmbedding(seeded.id, [1, 0, 0]);
1336
+ const handler = events.find((e) => e.name === "session/event").fn;
1337
+ await handler(sessionFor("t8"), { seq: 2, type: "turn/end" });
1338
+ assert.equal(store.count(), 1, "no new row: the rephrase was absorbed by cosine");
1339
+ const merged = store.getById(seeded.id);
1340
+ assert.ok(merged.content.includes("重复踩坑"), "content appended into the existing row");
1341
+ assert.ok((merged.content_history ?? []).length >= 1, "content_history keeps the prior version");
1342
+ const audit = store.listLlmAudits({ source: "autoSummarize" })[0];
1343
+ assert.equal(audit.metadata?.deduped, 1);
1344
+ assert.equal(audit.metadata?.mode, "vector");
1345
+ });
1346
+
1347
+ test("issue#127: no embedder means vector dedupe silently falls back to a normal write", async () => {
1348
+ const { events, store } = setup(
1349
+ { summarizeDedupeMode: "vector" },
1350
+ { stream: streamOf([{ type: "pitfall", title: "无关的一条", content: "内容", importance: 4 }]) }
1351
+ );
1352
+ const handler = events.find((e) => e.name === "session/event").fn;
1353
+ await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
1354
+ assert.equal(store.count(), 1, "dedupe failure never blocks the write");
1355
+ });
1356
+
1357
+ test("issue#127: the five summarize knobs are whitelisted and range-checked", () => {
1358
+ const store = createStore(":memory:");
1359
+ const settings = createSettings(store.db);
1360
+ const merged = settings.setFeatureFlags({
1361
+ summarizeMinIntervalMinutes: 30,
1362
+ summarizeMaxEntriesPerRun: 5,
1363
+ summarizeDedupeMode: "vector",
1364
+ summarizeDedupeMinSim: 0.9,
1365
+ summarizeDedupeWindowHours: 12
1366
+ });
1367
+ assert.equal(merged.summarizeMinIntervalMinutes, 30);
1368
+ assert.equal(merged.summarizeDedupeMode, "vector");
1369
+ assert.equal(merged.summarizeDedupeMinSim, 0.9, "float thresholds round-trip (new numbers whitelist)");
1370
+ // 面板/API 写入走同一套逐键校验:越界与非法枚举必须被拒。
1371
+ assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMinSim: 0.3 }), /number in \[0.5, 0.99\]/);
1372
+ assert.throws(() => settings.setFeatureFlags({ summarizeDedupeMode: "semantic" }), /one of: off, title, vector/);
1373
+ store.close();
1374
+ });
1375
+
1376
+ // ── Issue #127 补测:fail-safe 与打点回滚的边界路径 ────────────────────────
1377
+
1378
+ test("issue#127: a throwing audit writer never breaks the min-interval skip path", async () => {
1379
+ const { events, store, service } = setup({ summarizeMinIntervalMinutes: 30 });
1380
+ const handler = events.find((e) => e.name === "session/event").fn;
1381
+ const session = sessionFor("t10");
1382
+ await handler(session, { seq: 2, type: "turn/end" });
1383
+ assert.equal(store.count(), 2);
1384
+ // 审计写入器抛错:跳过路径必须吞掉异常——审计是观测手段,绝不能反过来打断蒸馏。
1385
+ service.saveLlmAudit = () => { throw new Error("audit sink down"); };
1386
+ await handler(session, { seq: 3, type: "turn/end" });
1387
+ assert.equal(store.count(), 2, "the throttled turn stays a no-op instead of rejecting");
1388
+ });
1389
+
1390
+ test("issue#127: an aborted run rolls the interval claim back so the next turn is admitted", async () => {
1391
+ // 间隔取毫秒级小数值(schema 的粒度是 1 分钟,测试直接构造 config 绕开它),
1392
+ // 这样"回滚到上一次打点"与"保留本次打点"才有可观察差异:
1393
+ // 回滚 → 下一次落在 T1+gap 之外,应被接纳;
1394
+ // 不滚 → 距本次打点才几毫秒,必被挡。
1395
+ // 若沿用 gap=0,两种实现的对外行为完全相同,测试无法发现 else 分支写错。
1396
+ let call = 0;
1397
+ const { events } = setup({ summarizeMinIntervalMinutes: 0.01, distillRateLimitIntervalMs: 0 }, {
1398
+ stream() {
1399
+ call++;
1400
+ if (call === 2) {
1401
+ return (async function* () {
1402
+ yield { type: "block-start", block: { type: "text" } };
1403
+ yield { type: "text-delta", delta: "[]" };
1404
+ yield { type: "finish", kind: "aborted" };
1405
+ })();
1406
+ }
1407
+ return (async function* () {
1408
+ yield { type: "block-start", block: { type: "text" } };
1409
+ yield { type: "text-delta", delta: JSON.stringify([{ type: "decision", title: `第${call}条`, content: "内容", importance: 3 }]) };
1410
+ yield { type: "finish", kind: "ok" };
1411
+ })();
1412
+ }
1413
+ });
1414
+ const handler = events.find((e) => e.name === "session/event").fn;
1415
+ const session = sessionFor("t11");
1416
+ await handler(session, { seq: 2, type: "turn/end" }); // T1:成功,占用间隔
1417
+ assert.equal(call, 1, "the first turn distills");
1418
+ await new Promise((r) => setTimeout(r, 700)); // 越过 600ms 间隔窗
1419
+ session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
1420
+ await handler(session, { seq: 4, type: "turn/end" }); // T2:aborted → 回滚到 T1
1421
+ assert.equal(call, 2, "the aborted turn still reached the LLM call");
1422
+ await handler(session, { seq: 4, type: "turn/end" }); // 立刻再发:只有回滚到 T1 才会被接纳
1423
+ assert.equal(call, 3, "the aborted run did not consume the interval window");
1424
+ });
1425
+
1426
+ test("issue#127: a throwing candidate lookup degrades to undefined (service fail-safe)", async () => {
1427
+ const { service } = setup({ summarizeDedupeMode: "vector" });
1428
+ // 候选查询在 try 内抛错 → findSessionDuplicate 吞掉并返回 undefined(不向外抛)。
1429
+ const hostile = { get type() { throw new Error("store down"); }, title: "标题", content: "内容" };
1430
+ const hit = await service.findSessionDuplicate(hostile, { mode: "vector", source: "session:z" });
1431
+ assert.equal(hit, undefined, "a failed lookup yields no duplicate instead of throwing");
1432
+ });
1433
+
1434
+ test("issue#127: a dedupe lookup that yields nothing still lands the entry (never dropped)", async () => {
1435
+ // 走真实的 session/event 落库路径。只让去重判定不可用(service 层为何返回 undefined
1436
+ // 由上一条覆盖)——蒸馏必须照常写入,不能因为拿不到去重结论就把这一条丢掉。
1437
+ const { events, store, service } = setup(
1438
+ { summarizeDedupeMode: "vector" },
1439
+ { stream: streamOf([{ type: "decision", title: "必须落库", content: "内容", importance: 3 }]) }
1440
+ );
1441
+ service.findSessionDuplicate = async () => undefined;
1442
+ const handler = events.find((e) => e.name === "session/event").fn;
1443
+ await handler(sessionFor("t9"), { seq: 2, type: "turn/end" });
1444
+ assert.equal(store.count(), 1, "the entry is stored through the normal write path");
1445
+ assert.equal(store.all()[0].title, "必须落库");
1446
+ });
1447
+
1448
+ test("parseSummaryJson salvage only takes direct children of the outer array (issue #339 review)", () => {
1449
+ // 嵌套子数组里的对象不捞(模型没把它当顶层记忆产出),坏对象照旧丢弃。
1450
+ const nested = `[[{"type":"history","title":"nested","content":"kept"}], { " "type":"history"}]`;
1451
+ assert.deepEqual(parseSummaryJson(nested), []);
1452
+ });
1453
+
1454
+ test("parseSummaryJson salvage does not turn string-embedded objects into memories (issue #339 review)", () => {
1455
+ // 全数组解析失败(第二对象坏)+ 第一对象的 content 字符串里嵌着假记忆形状:
1456
+ // 盲扫不得把属性值对象当独立记忆——外层对象整体才是直接子项。
1457
+ const raw = `[
1458
+ {"type":"history","title":"good","content":"note { \\"type\\": \\"history\\", \\"title\\": \\"fake\\" }"},
1459
+ { " "type": "history", "title": "坏", "content": "x" }
1460
+ ]`;
1461
+ const parsed = parseSummaryJson(raw);
1462
+ assert.equal(parsed.length, 1);
1463
+ assert.equal(parsed[0].title, "good");
1464
+ assert.match(parsed[0].content, /fake/);
1465
+ });