@modusensus/dsh-mneme 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -8
- package/bin/dsh-mneme-mcp.mjs +11 -3
- package/lib/api-standalone.js +11 -0
- package/lib/api.js +21 -0
- package/lib/client.js +213 -31
- package/lib/config.js +106 -5
- package/lib/document.js +221 -0
- package/lib/dream/sleep.js +128 -12
- package/lib/dream.js +46 -11
- package/lib/guide.js +44 -0
- package/lib/heat.js +2 -0
- package/lib/index.js +65 -3
- package/lib/inject.js +91 -15
- package/lib/lang.js +6 -0
- package/lib/local-embedder.js +44 -6
- package/lib/organize.js +450 -0
- package/lib/recall-stats.js +23 -1
- package/lib/runtime/layout.js +30 -0
- package/lib/runtime/loader.js +4 -1
- package/lib/runtime/verify.js +19 -6
- package/lib/search/adaptive.js +38 -0
- package/lib/service.js +165 -12
- package/lib/settings.js +35 -4
- package/lib/store.js +126 -8
- package/lib/summarize.js +253 -8
- package/lib/tools.js +136 -8
- package/package.json +6 -6
- package/runtime-manifest.json +104 -174
- package/scripts/build-runtime-manifest.mjs +8 -1
- package/scripts/sync-test-badge.mjs +18 -12
- package/src/api-standalone.js +11 -0
- package/src/api.js +21 -0
- package/src/config.js +106 -5
- package/src/document.js +221 -0
- package/src/dream/sleep.js +128 -12
- package/src/dream.js +46 -11
- package/src/guide.js +44 -0
- package/src/heat.js +2 -0
- package/src/index.js +65 -3
- package/src/inject.js +91 -15
- package/src/lang.js +6 -0
- package/src/local-embedder.js +44 -6
- package/src/organize.js +450 -0
- package/src/recall-stats.js +23 -1
- package/src/runtime/layout.js +30 -0
- package/src/runtime/loader.js +4 -1
- package/src/runtime/verify.js +19 -6
- package/src/search/adaptive.js +38 -0
- package/src/service.js +165 -12
- package/src/settings.js +35 -4
- package/src/store.js +126 -8
- package/src/summarize.js +253 -8
- package/src/tools.js +136 -8
- package/test/adaptive-inject-budget.test.js +85 -0
- package/test/api.test.js +66 -3
- package/test/client.test.js +63 -2
- package/test/document.test.js +523 -0
- package/test/dream.test.js +51 -0
- package/test/inject-content-cap.test.js +5 -3
- package/test/inject-pools.test.js +170 -0
- package/test/inject-recall.test.js +105 -0
- package/test/inject.test.js +35 -1
- package/test/lastrunat-persist.test.js +101 -0
- package/test/llm-audit.test.js +247 -0
- package/test/local-embedder.test.js +45 -2
- package/test/organize.test.js +418 -0
- package/test/recall-stats.test.js +7 -1
- package/test/runtime-loader.test.js +18 -0
- package/test/runtime-manifest.test.js +2 -1
- package/test/runtime-verify.test.js +65 -0
- package/test/sleep.test.js +154 -0
- package/test/store.test.js +13 -0
- package/test/summarize.test.js +407 -3
- package/test/tools-dto-schema-matrix.test.js +325 -0
- package/test/tools.test.js +36 -3
package/test/sleep.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { createSleepScheduler, runSleep } from "../src/dream/sleep.js";
|
|
4
|
+
import { Config } from "../src/config.js";
|
|
4
5
|
import { createStore } from "../src/store.js";
|
|
5
6
|
import { createService } from "../src/service.js";
|
|
6
7
|
import { createVectorIndex } from "../src/vector-index.js";
|
|
@@ -371,3 +372,156 @@ test("sleep: runSleep is abortable via signal between phases", async () => {
|
|
|
371
372
|
assert.equal(result.phases.conflicts, undefined, "aborted before any phase ran");
|
|
372
373
|
store.close();
|
|
373
374
|
});
|
|
375
|
+
|
|
376
|
+
// Issue #257:冲突/模式两阶段的输出预算原硬编码 2048——sleepActionSet=full
|
|
377
|
+
// 实测 24 对需约 6967 token,截断即 invalid decisions json 整轮失败。两阶段
|
|
378
|
+
// 必须读取 sleepMaxTokens 配置并透传到 ctx.llm.stream 的 options。
|
|
379
|
+
test("issue#257 conflict and pattern phases forward configured sleepMaxTokens", async () => {
|
|
380
|
+
const { service, store, vectorIndex } = setup();
|
|
381
|
+
const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
|
|
382
|
+
const seen = [];
|
|
383
|
+
const ctx = mockCtx((userText) =>
|
|
384
|
+
JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
385
|
+
);
|
|
386
|
+
const origStream = ctx.llm.stream.bind(ctx.llm);
|
|
387
|
+
ctx.llm.stream = async function* (options) {
|
|
388
|
+
if (options?.purpose === "sleep-conflict" || options?.purpose === "sleep-pattern") {
|
|
389
|
+
seen.push({ purpose: options.purpose, maxTokens: options.maxTokens });
|
|
390
|
+
}
|
|
391
|
+
if (options?.purpose === "sleep-pattern") {
|
|
392
|
+
// pattern 阶段喂确定性空结果(no patterns found → skipped),
|
|
393
|
+
// 不复用 conflict 决策(宽松模式下会被当 Fabricated 静默跳过)。
|
|
394
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
395
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
yield* origStream(options);
|
|
399
|
+
};
|
|
400
|
+
const result = await runSleep(ctx, service, baseConfig({ sleepMaxTokens: 6967 }), ctx.logger, { embedder, vectorIndex }, null);
|
|
401
|
+
assert.equal(result.status, "ok", "run completes with the configured budget");
|
|
402
|
+
assert.ok(seen.some((s) => s.purpose === "sleep-conflict" && s.maxTokens === 6967), "conflict phase reads sleepMaxTokens");
|
|
403
|
+
assert.ok(seen.some((s) => s.purpose === "sleep-pattern" && s.maxTokens === 6967), "pattern phase reads sleepMaxTokens");
|
|
404
|
+
store.close();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("issue#257 schema default for sleepMaxTokens is 8192 (was hardcoded 2048)", () => {
|
|
408
|
+
const cfg = Config({});
|
|
409
|
+
assert.equal(cfg.sleepMaxTokens, 8192, "schema default covers the measured full-set peak (6967)");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ------------------------------------------- sleep LLM accounting (issue #250)
|
|
413
|
+
// Bug8 给 dream 与 summarize 接记账时漏了 sleep:src/dream/sleep.js 有一份自己的
|
|
414
|
+
// streamText,签名里没有 onUsage,两条 LLM 链路(conflict 裁决 / pattern 挖掘)的
|
|
415
|
+
// token 与状态从未进 llm_audit_logs——面板因此只看到两条链路,回答不了「sleep 花了
|
|
416
|
+
// 多少」。下面两条用例锁定「审计行落库 + token 数值来自 usage chunk」。
|
|
417
|
+
// 两条链路共用同一处 streamText 改动,故放在同一轮 red-green 里。
|
|
418
|
+
|
|
419
|
+
/** 在 base ctx 的流里、finish 之前插入一个 usage chunk(形态同 llm-audit.test.js)。 */
|
|
420
|
+
function withUsageChunk(base, usageChunk) {
|
|
421
|
+
return {
|
|
422
|
+
...base,
|
|
423
|
+
llm: {
|
|
424
|
+
async *stream(options) {
|
|
425
|
+
for await (const chunk of base.llm.stream(options)) {
|
|
426
|
+
if (chunk.type === "finish") yield usageChunk;
|
|
427
|
+
yield chunk;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
test("issue#250 sleep conflict phase writes an llm_audit row with the streamed tokens", async () => {
|
|
435
|
+
const { service, store, vectorIndex } = setup();
|
|
436
|
+
const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
|
|
437
|
+
const ctx = withUsageChunk(
|
|
438
|
+
mockCtx(() => JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])),
|
|
439
|
+
{ type: "usage", usage: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 } }
|
|
440
|
+
);
|
|
441
|
+
const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
|
|
442
|
+
assert.equal(result.phases.conflicts.status, "ok", "conflict resolved");
|
|
443
|
+
|
|
444
|
+
const conflict = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_conflict");
|
|
445
|
+
assert.ok(conflict, "sleep_conflict audit row present");
|
|
446
|
+
assert.equal(conflict.trigger_source, "sleep");
|
|
447
|
+
assert.equal(conflict.status, "success");
|
|
448
|
+
assert.equal(conflict.model_id, "mock:sleep-model", "route actually used is recorded");
|
|
449
|
+
assert.equal(conflict.input_tokens, 1200, "input tokens come from chunk.usage");
|
|
450
|
+
assert.equal(conflict.output_tokens, 300, "output tokens come from chunk.usage");
|
|
451
|
+
assert.equal(conflict.total_tokens, 1500);
|
|
452
|
+
assert.ok(typeof conflict.duration_ms === "number" && conflict.duration_ms >= 0);
|
|
453
|
+
store.close();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("issue#250 sleep pattern phase writes an llm_audit row with the streamed tokens", async () => {
|
|
457
|
+
const { service, store } = setup();
|
|
458
|
+
const a = makeMemory(service, "模式A", "反复出现的模式A", "project");
|
|
459
|
+
const b = makeMemory(service, "模式B", "反复出现的模式B", "project");
|
|
460
|
+
const ctx = withUsageChunk(
|
|
461
|
+
mockCtx(() => JSON.stringify([
|
|
462
|
+
{ action: "create", type: "pattern", title: "真模式", content: "从 a 和 b 提取", importance: 3, evidence: [a.id, b.id] }
|
|
463
|
+
])),
|
|
464
|
+
{ type: "usage", usage: { inputTokens: 900, outputTokens: 250, totalTokens: 1150 } }
|
|
465
|
+
);
|
|
466
|
+
const result = await runSleep(ctx, service, baseConfig({ sleepPatternMinMemories: 10 }), ctx.logger, null, null);
|
|
467
|
+
assert.ok(result.phases.patterns, "pattern phase ran");
|
|
468
|
+
|
|
469
|
+
const pattern = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_pattern");
|
|
470
|
+
assert.ok(pattern, "sleep_pattern audit row present");
|
|
471
|
+
assert.equal(pattern.trigger_source, "sleep");
|
|
472
|
+
assert.equal(pattern.status, "success");
|
|
473
|
+
assert.equal(pattern.input_tokens, 900, "input tokens come from chunk.usage");
|
|
474
|
+
assert.equal(pattern.output_tokens, 250, "output tokens come from chunk.usage");
|
|
475
|
+
assert.equal(pattern.total_tokens, 1150);
|
|
476
|
+
store.close();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// 审计诚实性(issue #250):流式成功但输出里没有 JSON 数组时,phase 报 failed,
|
|
480
|
+
// 审计行也必须记 error——不能一个调用在两表里自相矛盾。
|
|
481
|
+
test("issue#250 sleep conflict parse failure is audited as status=error, not fake success", async () => {
|
|
482
|
+
const { service, store, vectorIndex } = setup();
|
|
483
|
+
const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
|
|
484
|
+
const ctx = mockCtx(() => "抱歉,我无法把裁决整理成 JSON。");
|
|
485
|
+
const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
|
|
486
|
+
assert.equal(result.phases.conflicts.status, "failed", "the phase reports the failure");
|
|
487
|
+
|
|
488
|
+
const row = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_conflict");
|
|
489
|
+
assert.ok(row, "the failed call is still audited");
|
|
490
|
+
assert.equal(row.status, "error", "a stream that returned unusable output is not a success");
|
|
491
|
+
assert.match(row.error_message, /no json array in llm output/);
|
|
492
|
+
store.close();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("issue#250 sleep pattern parse failure is audited as status=error, not fake success", async () => {
|
|
496
|
+
const { service, store } = setup();
|
|
497
|
+
makeMemory(service, "模式A", "反复出现的模式A", "project");
|
|
498
|
+
makeMemory(service, "模式B", "反复出现的模式B", "project");
|
|
499
|
+
const ctx = mockCtx(() => "这不是 JSON 数组");
|
|
500
|
+
const result = await runSleep(ctx, service, baseConfig({ sleepPatternMinMemories: 10 }), ctx.logger, null, null);
|
|
501
|
+
// pattern 阶段对不可解析输出的既定语义是 skipped("no patterns found")——那是
|
|
502
|
+
// 「有没有铸造出模式」的结论,不是「这次调用成没成功」的结论,此处不改上游语义。
|
|
503
|
+
assert.equal(result.phases.patterns.status, "skipped", "the phase keeps its existing semantics");
|
|
504
|
+
|
|
505
|
+
const row = service.listLlmAudits({ source: "sleep" }).find((r) => r.operation_type === "sleep_pattern");
|
|
506
|
+
assert.ok(row, "the call still consumed tokens and is still audited");
|
|
507
|
+
assert.equal(row.status, "error", "the audit records the call's truth, not the phase's conclusion");
|
|
508
|
+
assert.match(row.error_message, /no json array in llm output/);
|
|
509
|
+
store.close();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// 回归护栏:llmAudit.enabled === false 时 auditedSleepLlm 整段早退,auditError 钩子
|
|
513
|
+
// 不会被调用。所以两个 phase 的控制流必须各自解析输出、不能依赖那个钩子的副作用
|
|
514
|
+
// ——dream 的 runAuditedLlm 正是那样踩了(关掉审计 flag 会让 autoDream 直接失效)。
|
|
515
|
+
test("issue#250 sleep still adjudicates normally when llmAudit.enabled === false", async () => {
|
|
516
|
+
const { service, store, vectorIndex } = setup();
|
|
517
|
+
const { a, b } = seedConflictPair(service, vectorIndex, 1.0);
|
|
518
|
+
const ctx = mockCtx(() =>
|
|
519
|
+
JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
|
|
520
|
+
);
|
|
521
|
+
const config = baseConfig({ llmAudit: { enabled: false } });
|
|
522
|
+
const result = await runSleep(ctx, service, config, ctx.logger, { embedder, vectorIndex }, null);
|
|
523
|
+
assert.equal(result.phases.conflicts.status, "ok", "turning the audit off must not break sleep");
|
|
524
|
+
assert.equal(service.getById(b.id).archived, true, "the arbitration still applied");
|
|
525
|
+
assert.equal(service.listLlmAudits().length, 0, "no audit rows when disabled");
|
|
526
|
+
store.close();
|
|
527
|
+
});
|
package/test/store.test.js
CHANGED
|
@@ -16,6 +16,15 @@ test("createStore initializes schema and opens db", () => {
|
|
|
16
16
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='memories'"
|
|
17
17
|
).get();
|
|
18
18
|
assert.ok(row, "memories table exists");
|
|
19
|
+
const cursorTable = store.db.prepare(
|
|
20
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='distill_cursors'"
|
|
21
|
+
).get();
|
|
22
|
+
assert.ok(cursorTable, "distill cursor table exists");
|
|
23
|
+
assert.equal(store.getDistillCursor("session-1"), undefined);
|
|
24
|
+
const saved = store.setDistillCursor("session-1", 4);
|
|
25
|
+
assert.equal(saved.last_seq, 4);
|
|
26
|
+
assert.ok(saved.updated_at);
|
|
27
|
+
assert.equal(store.setDistillCursor("session-1", 2).last_seq, 4, "cursor never moves backwards");
|
|
19
28
|
store.close();
|
|
20
29
|
});
|
|
21
30
|
|
|
@@ -173,6 +182,10 @@ test("schema migration adds archived column to legacy database", () => {
|
|
|
173
182
|
const store = createStore(dbPath);
|
|
174
183
|
const cols = store.db.prepare("PRAGMA table_info(memories)").all().map((c) => c.name);
|
|
175
184
|
assert.ok(cols.includes("archived"), "archived column added");
|
|
185
|
+
const cursorTable = store.db.prepare(
|
|
186
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='distill_cursors'"
|
|
187
|
+
).get();
|
|
188
|
+
assert.ok(cursorTable, "new cursor table is created for legacy databases");
|
|
176
189
|
store.close();
|
|
177
190
|
} finally {
|
|
178
191
|
rmSync(dir, { recursive: true, force: true });
|
package/test/summarize.test.js
CHANGED
|
@@ -1,12 +1,54 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
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";
|
|
3
6
|
import { createStore } from "../src/store.js";
|
|
4
7
|
import { createService } from "../src/service.js";
|
|
5
|
-
import { createSummarizer, parseSummaryJson } from "../src/summarize.js";
|
|
8
|
+
import { createSummarizer, parseSummaryJson, parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "../src/summarize.js";
|
|
6
9
|
import { createSettings } from "../src/settings.js";
|
|
7
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
|
+
|
|
8
50
|
function setup(over = {}, opts = {}) {
|
|
9
|
-
const store = createStore(":memory:");
|
|
51
|
+
const store = opts.store ?? createStore(":memory:");
|
|
10
52
|
const service = createService({ store, mirror: null, config: {} });
|
|
11
53
|
const events = [];
|
|
12
54
|
const calls = [];
|
|
@@ -36,7 +78,7 @@ function setup(over = {}, opts = {}) {
|
|
|
36
78
|
}
|
|
37
79
|
};
|
|
38
80
|
const config = { autoSummarize: true, ...over };
|
|
39
|
-
const summarizer = createSummarizer(ctx, service, config);
|
|
81
|
+
const summarizer = createSummarizer(ctx, service, config, opts.deps ?? {});
|
|
40
82
|
return { store, service, events, calls, summarizer };
|
|
41
83
|
}
|
|
42
84
|
|
|
@@ -151,6 +193,218 @@ test("reads events from snapshotEvents() when Session.events is absent", async (
|
|
|
151
193
|
assert.equal(store.count(), 2);
|
|
152
194
|
});
|
|
153
195
|
|
|
196
|
+
// --- Issue #239:成本感知级联(零 LLM 预判)与有界检查点 ---------------------
|
|
197
|
+
// 验收对齐 #239:日志可见 skip 原因、默认配置下行为零变化、开启后调用量下降。
|
|
198
|
+
|
|
199
|
+
test("#239 预判默认关:阈值为 0 时不改变现状,照常发起调用", async () => {
|
|
200
|
+
const { events, calls, store } = setup();
|
|
201
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
202
|
+
const session = {
|
|
203
|
+
id: "s239-default",
|
|
204
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
205
|
+
events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
|
|
206
|
+
};
|
|
207
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
208
|
+
assert.equal(calls.length, 1, "默认不预判:再短的窗口也照常蒸馏");
|
|
209
|
+
assert.equal(store.count(), 2);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费游标", async () => {
|
|
213
|
+
const { events, calls, service, store } = setup({ summarizeMinWindowChars: 1000 });
|
|
214
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
215
|
+
const session = {
|
|
216
|
+
id: "s239-small",
|
|
217
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
218
|
+
events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
|
|
219
|
+
};
|
|
220
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
221
|
+
assert.equal(calls.length, 0, "短窗口不得发起 LLM 调用");
|
|
222
|
+
assert.equal(store.count(), 0, "被拦下就不该写入记忆");
|
|
223
|
+
const rows = service.listLlmAudits();
|
|
224
|
+
assert.equal(rows[0].status, "skipped");
|
|
225
|
+
assert.equal(rows[0].error_message, "window-too-small", "skip 原因必须可观测");
|
|
226
|
+
|
|
227
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
228
|
+
assert.equal(calls.length, 0);
|
|
229
|
+
assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("#239 窗口达到阈值时预判放行(不误杀)", async () => {
|
|
233
|
+
const { events, calls } = setup({ summarizeMinWindowChars: 5 });
|
|
234
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
235
|
+
const session = {
|
|
236
|
+
id: "s239-pass",
|
|
237
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
238
|
+
events: [userMessage("这是一段足够长的用户输入", 1), { seq: 2, type: "turn/end" }]
|
|
239
|
+
};
|
|
240
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
241
|
+
assert.equal(calls.length, 1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("#239 每会话 run 预算:额度用尽后不再调用,审计写明原因", async () => {
|
|
245
|
+
const { events, calls, service } = setup({ summarizeMaxRunsPerSession: 1, distillRateLimitIntervalMs: 0 });
|
|
246
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
247
|
+
const session = {
|
|
248
|
+
id: "s239-cap",
|
|
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
|
+
|
|
255
|
+
session.events.push(userMessage("第二轮", 3), { seq: 4, type: "turn/end" });
|
|
256
|
+
await handler(session, { seq: 4, type: "turn/end" });
|
|
257
|
+
assert.equal(calls.length, 1, "预算用尽后不得再调用");
|
|
258
|
+
const rows = service.listLlmAudits();
|
|
259
|
+
assert.equal(rows[0].status, "skipped");
|
|
260
|
+
assert.equal(rows[0].error_message, "max-runs-per-session");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("#239 预算只计真实调用:被预判拦下的窗口不消耗额度", async () => {
|
|
264
|
+
const { events, calls } = setup({ summarizeMaxRunsPerSession: 1, summarizeMinWindowChars: 10, distillRateLimitIntervalMs: 0 });
|
|
265
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
266
|
+
const session = {
|
|
267
|
+
id: "s239-budget",
|
|
268
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
269
|
+
events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
|
|
270
|
+
};
|
|
271
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
272
|
+
assert.equal(calls.length, 0, "第一轮被零 LLM 预判拦下");
|
|
273
|
+
|
|
274
|
+
session.events.push(userMessage("第二轮写得足够长,应该放行并消耗唯一的一次额度", 3), { seq: 4, type: "turn/end" });
|
|
275
|
+
await handler(session, { seq: 4, type: "turn/end" });
|
|
276
|
+
assert.equal(calls.length, 1, "零成本窗口不该吃掉预算");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// --- Issue #239 第 4 项:错峰队列 -------------------------------------------------
|
|
280
|
+
|
|
281
|
+
test("#239-4 高峰时段解析:合法 / 多段 / 跨零点 / 星期前缀 / 非法一律按未配置", () => {
|
|
282
|
+
assert.equal(parsePeakSpec(""), null);
|
|
283
|
+
assert.equal(parsePeakSpec(" "), null);
|
|
284
|
+
assert.deepEqual(parsePeakSpec("09:00-18:00"), { days: null, windows: [{ start: 540, end: 1080 }] });
|
|
285
|
+
assert.deepEqual(parsePeakSpec("09:00-12:00, 14:00-18:00"), {
|
|
286
|
+
days: null,
|
|
287
|
+
windows: [{ start: 540, end: 720 }, { start: 840, end: 1080 }]
|
|
288
|
+
});
|
|
289
|
+
assert.deepEqual(parsePeakSpec("23:00-06:00"), { days: null, windows: [{ start: 1380, end: 360 }] });
|
|
290
|
+
// 星期前缀:ISO 1=周一…7=周日,也认 mon..sun;支持列表与跨周环绕
|
|
291
|
+
assert.deepEqual(parsePeakSpec("mon-fri 08:00-12:00,14:00-18:00"), {
|
|
292
|
+
days: [1, 2, 3, 4, 5],
|
|
293
|
+
windows: [{ start: 480, end: 720 }, { start: 840, end: 1080 }]
|
|
294
|
+
});
|
|
295
|
+
assert.deepEqual(parsePeakSpec("1-5 08:00-12:00").days, [1, 2, 3, 4, 5]);
|
|
296
|
+
assert.deepEqual(parsePeakSpec("sat,sun 10:00-12:00").days, [6, 7]);
|
|
297
|
+
assert.deepEqual(parsePeakSpec("fri-mon 10:00-12:00").days, [1, 5, 6, 7], "跨周环绕");
|
|
298
|
+
// 排程是省钱手段,绝不该因为写错格式把蒸馏停掉:任何非法写法都当作「关闭」。
|
|
299
|
+
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"]) {
|
|
300
|
+
assert.equal(parsePeakSpec(bad), null, `${bad} 应视为未配置`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("#239-4 高峰判定与补跑时刻(跨零点、右开区间)", () => {
|
|
305
|
+
const at = (h, m) => new Date(2026, 8, 19, h, m, 30);
|
|
306
|
+
assert.equal(isInPeakWindow(at(10, 0), "09:00-18:00"), true);
|
|
307
|
+
assert.equal(isInPeakWindow(at(18, 0), "09:00-18:00"), false, "右开区间:18:00 已出高峰");
|
|
308
|
+
assert.equal(isInPeakWindow(at(3, 0), "23:00-06:00"), true, "跨零点时段");
|
|
309
|
+
assert.equal(isInPeakWindow(at(12, 0), "23:00-06:00"), false);
|
|
310
|
+
assert.equal(isInPeakWindow(at(10, 0), ""), false, "未配置 = 永不高峰");
|
|
311
|
+
const next = nextOffPeakAt(at(10, 30), "09:00-18:00");
|
|
312
|
+
assert.equal(next.getHours(), 18);
|
|
313
|
+
assert.equal(next.getMinutes(), 0);
|
|
314
|
+
assert.equal(nextOffPeakAt(at(3, 0), "23:00-06:00").getHours(), 6, "跨零点取次日 06:00 结束点");
|
|
315
|
+
assert.equal(nextOffPeakAt(at(20, 0), "09:00-18:00"), null, "非高峰时刻无需补跑");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// 夹具:按 ISO 星期几(1=周一…7=周日)从当月 1 号推算出日期,避免把星期写死在测试里。
|
|
319
|
+
function dateOnIsoDay(isoDay, hour, minute) {
|
|
320
|
+
const first = new Date(2026, 8, 1, hour, minute, 0);
|
|
321
|
+
const firstIso = first.getDay() === 0 ? 7 : first.getDay();
|
|
322
|
+
return new Date(2026, 8, 1 + ((isoDay - firstIso + 7) % 7), hour, minute, 0);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
test("#239-4 星期过滤:工作日高峰不误伤周末(跨零点段按「开窗那天」认星期)", () => {
|
|
326
|
+
const spec = "mon-fri 08:00-12:00,14:00-18:00";
|
|
327
|
+
assert.equal(dateOnIsoDay(1, 9, 0).getDay(), 1, "夹具自检:周一");
|
|
328
|
+
assert.equal(dateOnIsoDay(6, 9, 0).getDay(), 6, "夹具自检:周六");
|
|
329
|
+
assert.equal(isInPeakWindow(dateOnIsoDay(1, 9, 0), spec), true, "工作日 09:00 在高峰");
|
|
330
|
+
assert.equal(isInPeakWindow(dateOnIsoDay(1, 12, 30), spec), false, "工作日午休不在高峰");
|
|
331
|
+
assert.equal(isInPeakWindow(dateOnIsoDay(6, 9, 0), spec), false, "周六同刻不在高峰(按周计费)");
|
|
332
|
+
assert.equal(isInPeakWindow(dateOnIsoDay(7, 15, 0), spec), false, "周日下午不在高峰");
|
|
333
|
+
assert.equal(nextOffPeakAt(dateOnIsoDay(1, 9, 0), spec).getHours(), 12, "工作日顺延到 12:00");
|
|
334
|
+
assert.equal(nextOffPeakAt(dateOnIsoDay(6, 9, 0), spec), null, "周末无需顺延");
|
|
335
|
+
// 跨零点段:周五 23:00 开的窗口延续到周六凌晨,仍算高峰;周六开的则不覆盖周日
|
|
336
|
+
const overnight = "mon-fri 23:00-06:00";
|
|
337
|
+
assert.equal(isInPeakWindow(dateOnIsoDay(6, 2, 0), overnight), true, "周六 02:00 属周五开的窗口");
|
|
338
|
+
assert.equal(isInPeakWindow(dateOnIsoDay(7, 2, 0), overnight), false, "周日 02:00 属周六开的窗口(周六不在集合内)");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("#239-4 默认关:未配置高峰时行为与现状一致", async () => {
|
|
342
|
+
const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
|
|
343
|
+
const { events, calls } = setup({}, { deps: clock.deps });
|
|
344
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
345
|
+
const session = {
|
|
346
|
+
id: "s239-peak-off",
|
|
347
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
348
|
+
events: [userMessage("普通一轮", 1), { seq: 2, type: "turn/end" }]
|
|
349
|
+
};
|
|
350
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
351
|
+
assert.equal(calls.length, 1, "未配置高峰 → 照常蒸馏");
|
|
352
|
+
assert.equal(clock.pending(), 0, "不该挂补跑定时器");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("#239-4 高峰内不调模型:skip 审计 + 不消费游标,非高峰补跑整窗", async () => {
|
|
356
|
+
const clock = fakeClock(new Date(2026, 8, 19, 10, 0, 0));
|
|
357
|
+
const { events, calls, service, store } = setup(
|
|
358
|
+
{ summarizePeakHours: "09:00-18:00", distillRateLimitIntervalMs: 0 },
|
|
359
|
+
{ deps: clock.deps }
|
|
360
|
+
);
|
|
361
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
362
|
+
const session = {
|
|
363
|
+
id: "s239-peak",
|
|
364
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
365
|
+
events: [userMessage("高峰里的第一段", 1), { seq: 2, type: "turn/end" }]
|
|
366
|
+
};
|
|
367
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
368
|
+
assert.equal(calls.length, 0, "高峰期不得调模型");
|
|
369
|
+
assert.equal(store.count(), 0, "不该写入记忆");
|
|
370
|
+
const rows = service.listLlmAudits();
|
|
371
|
+
assert.equal(rows[0].status, "skipped");
|
|
372
|
+
assert.equal(rows[0].error_message, "peak-hours", "skip 原因必须可观测");
|
|
373
|
+
assert.equal(clock.pending(), 1, "应挂一个补跑定时器");
|
|
374
|
+
|
|
375
|
+
// 高峰期间又来一轮:游标未消费 → 窗口累积;仍不调模型、定时器不叠加
|
|
376
|
+
session.events.push(userMessage("高峰里的第二段", 3), { seq: 4, type: "turn/end" });
|
|
377
|
+
await handler(session, { seq: 4, type: "turn/end" });
|
|
378
|
+
assert.equal(calls.length, 0);
|
|
379
|
+
assert.equal(clock.pending(), 1, "每会话只挂一个定时器");
|
|
380
|
+
|
|
381
|
+
// 推到非高峰:一次补跑,且蒸到的是累积后的整窗
|
|
382
|
+
await clock.advanceTo(new Date(2026, 8, 19, 18, 0, 5));
|
|
383
|
+
assert.equal(calls.length, 1, "非高峰补跑一次");
|
|
384
|
+
const prompt = JSON.stringify(calls[0].messages);
|
|
385
|
+
assert.ok(prompt.includes("高峰里的第一段") && prompt.includes("高峰里的第二段"),
|
|
386
|
+
"补跑应蒸馏累积后的完整窗口");
|
|
387
|
+
assert.ok(store.count() > 0, "补跑真的写入了记忆");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("#239-4 顺延上限:长高峰到点照跑,不饿死蒸馏", async () => {
|
|
391
|
+
const clock = fakeClock(new Date(2026, 8, 19, 9, 0, 0));
|
|
392
|
+
const { events, calls } = setup(
|
|
393
|
+
{ summarizePeakHours: "09:00-18:00", summarizePeakMaxDeferMinutes: 30, distillRateLimitIntervalMs: 0 },
|
|
394
|
+
{ deps: clock.deps }
|
|
395
|
+
);
|
|
396
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
397
|
+
const session = {
|
|
398
|
+
id: "s239-peak-cap",
|
|
399
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
400
|
+
events: [userMessage("长高峰里的一轮", 1), { seq: 2, type: "turn/end" }]
|
|
401
|
+
};
|
|
402
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
403
|
+
assert.equal(calls.length, 0, "高峰内先顺延");
|
|
404
|
+
await clock.advance(31 * 60000);
|
|
405
|
+
assert.equal(calls.length, 1, "上限到点仍处高峰也照跑(bypassPeak)");
|
|
406
|
+
});
|
|
407
|
+
|
|
154
408
|
test("does not call the LLM when no event was added after the last successful seq", async () => {
|
|
155
409
|
const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
|
|
156
410
|
const handler = events.find((e) => e.name === "session/event").fn;
|
|
@@ -172,6 +426,94 @@ test("does not call the LLM when no event was added after the last successful se
|
|
|
172
426
|
assert.equal(ranges[1][0], 2, "the successful event seq is passed as the next snapshot lower bound");
|
|
173
427
|
});
|
|
174
428
|
|
|
429
|
+
test("resumes a persisted seq cursor after the summarizer is restarted", async () => {
|
|
430
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-distill-cursor-"));
|
|
431
|
+
const dbPath = join(dir, "memory.db");
|
|
432
|
+
let firstStore;
|
|
433
|
+
let firstSummarizer;
|
|
434
|
+
let secondStore;
|
|
435
|
+
let secondSummarizer;
|
|
436
|
+
try {
|
|
437
|
+
firstStore = createStore(dbPath);
|
|
438
|
+
const first = setup({ distillRateLimitIntervalMs: 0 }, { store: firstStore });
|
|
439
|
+
firstSummarizer = first.summarizer;
|
|
440
|
+
const session = {
|
|
441
|
+
id: "s-restart",
|
|
442
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
443
|
+
events: [userMessage("重启前的窗口", 1), { seq: 2, type: "turn/end" }]
|
|
444
|
+
};
|
|
445
|
+
const firstHandler = first.events.find((e) => e.name === "session/event").fn;
|
|
446
|
+
await firstHandler(session, { seq: 2, type: "turn/end" });
|
|
447
|
+
assert.equal(first.calls.length, 1);
|
|
448
|
+
assert.equal(firstStore.getDistillCursor("s-restart").last_seq, 2);
|
|
449
|
+
|
|
450
|
+
firstSummarizer.dispose();
|
|
451
|
+
firstStore.close();
|
|
452
|
+
firstSummarizer = undefined;
|
|
453
|
+
firstStore = undefined;
|
|
454
|
+
|
|
455
|
+
secondStore = createStore(dbPath);
|
|
456
|
+
const second = setup({ distillRateLimitIntervalMs: 0 }, { store: secondStore });
|
|
457
|
+
secondSummarizer = second.summarizer;
|
|
458
|
+
const secondHandler = second.events.find((e) => e.name === "session/event").fn;
|
|
459
|
+
await secondHandler(session, { seq: 2, type: "turn/end" });
|
|
460
|
+
|
|
461
|
+
assert.equal(second.calls.length, 0, "a restarted summarizer must reuse the persisted cursor");
|
|
462
|
+
assert.equal(secondStore.count(), 2, "the restart must not duplicate memories");
|
|
463
|
+
assert.equal(secondStore.getDistillCursor("s-restart").last_seq, 2);
|
|
464
|
+
} finally {
|
|
465
|
+
secondSummarizer?.dispose();
|
|
466
|
+
secondStore?.close();
|
|
467
|
+
firstSummarizer?.dispose();
|
|
468
|
+
firstStore?.close();
|
|
469
|
+
rmSync(dir, { recursive: true, force: true });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("treats a valid empty summary as a successful cursor commit", async () => {
|
|
474
|
+
const { events, store, calls } = setup(
|
|
475
|
+
{ distillRateLimitIntervalMs: 0 },
|
|
476
|
+
{ stream: streamOf([]) }
|
|
477
|
+
);
|
|
478
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
479
|
+
const session = {
|
|
480
|
+
id: "s-empty-summary",
|
|
481
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
482
|
+
events: [userMessage("这一轮没有可保存的长期记忆", 1), { seq: 2, type: "turn/end" }]
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
486
|
+
assert.equal(calls.length, 1);
|
|
487
|
+
assert.equal(store.count(), 0);
|
|
488
|
+
assert.equal(store.getDistillCursor(session.id).last_seq, 2);
|
|
489
|
+
|
|
490
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
491
|
+
assert.equal(calls.length, 1, "a valid empty summary must consume the window once");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("advances the cursor to the window end for mixed valid and invalid entries", async () => {
|
|
495
|
+
const valid = { type: "history", title: "有效摘要", content: "只保存这一条", importance: 3 };
|
|
496
|
+
const { events, store, calls } = setup(
|
|
497
|
+
{ distillRateLimitIntervalMs: 0 },
|
|
498
|
+
{ stream: streamOf([valid, { type: "unknown", title: "无效摘要", content: "忽略" }, null]) }
|
|
499
|
+
);
|
|
500
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
501
|
+
const session = {
|
|
502
|
+
id: "s-mixed-summary",
|
|
503
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
504
|
+
events: [userMessage("混合摘要窗口", 1), { seq: 2, type: "assistant/message" }, { seq: 3, type: "turn/end" }]
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
508
|
+
assert.equal(calls.length, 1);
|
|
509
|
+
assert.equal(store.count(), 1);
|
|
510
|
+
assert.equal(store.all()[0].title, valid.title);
|
|
511
|
+
assert.equal(store.getDistillCursor(session.id).last_seq, 3);
|
|
512
|
+
|
|
513
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
514
|
+
assert.equal(calls.length, 1, "a mixed summary must consume the window once");
|
|
515
|
+
});
|
|
516
|
+
|
|
175
517
|
test("distills only new seq events and keeps the recent tail when a window exceeds distillMaxChars", async () => {
|
|
176
518
|
const { events, calls } = setup(
|
|
177
519
|
{ distillMaxChars: 80, distillRateLimitIntervalMs: 0 },
|
|
@@ -403,10 +745,12 @@ test("rolls back partial memory writes so retrying a failed window does not dupl
|
|
|
403
745
|
await handler(session, { seq: 2, type: "turn/end" });
|
|
404
746
|
assert.equal(calls.length, 1);
|
|
405
747
|
assert.equal(store.count(), 0, "a failed write transaction must leave no partial memory");
|
|
748
|
+
assert.equal(store.getDistillCursor("s-partial-write"), undefined, "a failed write must not advance the cursor");
|
|
406
749
|
|
|
407
750
|
await handler(session, { seq: 2, type: "turn/end" });
|
|
408
751
|
assert.equal(calls.length, 2, "the failed seq window must be retried");
|
|
409
752
|
assert.equal(store.count(), 2);
|
|
753
|
+
assert.equal(store.getDistillCursor("s-partial-write").last_seq, 2);
|
|
410
754
|
assert.equal(store.all().find((memory) => memory.title === "第一条")?.content, "第一条内容");
|
|
411
755
|
|
|
412
756
|
await handler(session, { seq: 2, type: "turn/end" });
|
|
@@ -414,6 +758,66 @@ test("rolls back partial memory writes so retrying a failed window does not dupl
|
|
|
414
758
|
assert.equal(store.count(), 2);
|
|
415
759
|
});
|
|
416
760
|
|
|
761
|
+
test("rolls back memory writes when persisting the cursor fails", async () => {
|
|
762
|
+
const { events, store, service, calls } = setup(
|
|
763
|
+
{ distillRateLimitIntervalMs: 0 },
|
|
764
|
+
{ stream: streamOf([{ type: "history", title: "游标失败", content: "事务应回滚", importance: 3 }]) }
|
|
765
|
+
);
|
|
766
|
+
const originalSetCursor = service.setDistillCursor.bind(service);
|
|
767
|
+
let failOnce = true;
|
|
768
|
+
service.setDistillCursor = (sessionId, lastSeq) => {
|
|
769
|
+
if (failOnce) {
|
|
770
|
+
failOnce = false;
|
|
771
|
+
throw new Error("simulated cursor write failure");
|
|
772
|
+
}
|
|
773
|
+
return originalSetCursor(sessionId, lastSeq);
|
|
774
|
+
};
|
|
775
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
776
|
+
const session = {
|
|
777
|
+
id: "s-cursor-write-failure",
|
|
778
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
779
|
+
events: [userMessage("游标写入失败也不能丢窗口", 1), { seq: 2, type: "turn/end" }]
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
783
|
+
assert.equal(calls.length, 1);
|
|
784
|
+
assert.equal(store.count(), 0, "cursor failure must roll back the memory write");
|
|
785
|
+
assert.equal(store.getDistillCursor(session.id), undefined);
|
|
786
|
+
const failedAudit = service.listLlmAudits({ source: "autoSummarize" }).find(
|
|
787
|
+
(audit) => audit.error_message === "simulated cursor write failure"
|
|
788
|
+
);
|
|
789
|
+
assert.equal(failedAudit?.status, "error", "cursor failure must not be audited as success");
|
|
790
|
+
|
|
791
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
792
|
+
assert.equal(calls.length, 2, "the failed cursor window must be retryable");
|
|
793
|
+
assert.equal(store.count(), 1);
|
|
794
|
+
assert.equal(store.getDistillCursor(session.id).last_seq, 2);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("falls back to the in-memory cursor when service lacks setDistillCursor", async () => {
|
|
798
|
+
// 旧版 service(#274 之前)没有持久化游标:缺方法必须降级为内存游标照常蒸馏,
|
|
799
|
+
// 而不是抛错打断每轮蒸馏。方法存在但抛错走的是上一条回滚用例,两者不可混淆。
|
|
800
|
+
const { events, store, service, calls } = setup(
|
|
801
|
+
{ distillRateLimitIntervalMs: 0 },
|
|
802
|
+
{ stream: streamOf([{ type: "history", title: "降级游标", content: "缺方法也能蒸馏", importance: 3 }]) }
|
|
803
|
+
);
|
|
804
|
+
delete service.setDistillCursor; // 模拟旧版 service:实例属性,直接删掉
|
|
805
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
806
|
+
const session = {
|
|
807
|
+
id: "s-no-cursor-api",
|
|
808
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
809
|
+
events: [userMessage("旧宿主没有游标接口", 1), { seq: 2, type: "turn/end" }]
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
813
|
+
assert.equal(calls.length, 1, "distillation must still run without a persisted cursor");
|
|
814
|
+
assert.equal(store.count(), 1, "the memory itself must be saved");
|
|
815
|
+
assert.equal(store.getDistillCursor(session.id), undefined, "nothing may be persisted without the API");
|
|
816
|
+
|
|
817
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
818
|
+
assert.equal(calls.length, 1, "the in-memory cursor must prevent re-distillation in this process");
|
|
819
|
+
});
|
|
820
|
+
|
|
417
821
|
test("uses summarizeProvider/summarizeModel config override when set", async () => {
|
|
418
822
|
const { events, calls } = setup({
|
|
419
823
|
summarizeProvider: "aliyun",
|