@modusensus/dsh-mneme 0.9.1 → 0.9.3
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 +12 -10
- package/bin/cli.mjs +61 -7
- package/docs/CONFIGURATION.md +26 -2
- package/docs/WORKFLOWS.md +70 -3
- package/lib/api.js +176 -3
- package/lib/capabilities.js +1 -1
- package/lib/client.js +170 -16
- package/lib/config.js +37 -2
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +52 -22
- package/lib/index.js +15 -0
- package/lib/pipeline-nudge-auto.js +126 -0
- package/lib/pipeline-nudge.js +337 -28
- package/lib/pipeline-runs.js +1 -1
- package/lib/pipeline.js +4 -4
- package/lib/scope-state.js +277 -0
- package/lib/scope.js +30 -1
- package/lib/service.js +80 -3
- package/lib/settings.js +12 -1
- package/lib/store.js +139 -0
- package/lib/tools.js +71 -0
- package/package.json +1 -1
- package/src/api.js +176 -3
- package/src/capabilities.js +1 -1
- package/src/config.js +37 -2
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +52 -22
- package/src/index.js +15 -0
- package/src/pipeline-nudge-auto.js +126 -0
- package/src/pipeline-nudge.js +337 -28
- package/src/pipeline-runs.js +1 -1
- package/src/pipeline.js +4 -4
- package/src/scope-state.js +277 -0
- package/src/scope.js +30 -1
- package/src/service.js +80 -3
- package/src/settings.js +12 -1
- package/src/store.js +139 -0
- package/src/tools.js +71 -0
- package/test/api-nudge-route.test.js +259 -0
- package/test/api.test.js +118 -1
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +103 -4
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/pipeline-nudge-auto.test.js +224 -0
- package/test/pipeline-nudge.test.js +161 -6
- package/test/readme-claims.test.js +68 -0
- package/test/scope-optional-service.test.js +110 -0
- package/test/scope-state.test.js +481 -0
- package/test/session-usage.test.js +11 -2
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
package/test/api.test.js
CHANGED
|
@@ -538,6 +538,63 @@ test("GET /api/dsh-mneme/semantic reports configured/coverage/degraded", async (
|
|
|
538
538
|
assert.equal(noIndex.degraded, false);
|
|
539
539
|
});
|
|
540
540
|
|
|
541
|
+
// --- distill-tx-embed-gap 后续:/semantic 区分「有向量」与「可检索」 -----------
|
|
542
|
+
|
|
543
|
+
test("GET /api/dsh-mneme/semantic reports per-dimension/per-model vector breakdown", async () => {
|
|
544
|
+
const { routes, store, service } = setup(undefined);
|
|
545
|
+
// 三种状态混进一个库:3 维 2 条、旧 4 维 1 条、不可解析 1 条;
|
|
546
|
+
// 模型出身 m1#aaa 一行、m2#bbb 一行、无出身两行。
|
|
547
|
+
const a = service.saveWithDedupe({ type: "preference", title: "同维A", content: "x" }).memory;
|
|
548
|
+
const b = service.saveWithDedupe({ type: "preference", title: "同维B", content: "x" }).memory;
|
|
549
|
+
const c = service.saveWithDedupe({ type: "preference", title: "旧维C", content: "x" }).memory;
|
|
550
|
+
const d = service.saveWithDedupe({ type: "preference", title: "坏D", content: "x" }).memory;
|
|
551
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), a.id);
|
|
552
|
+
store.db.prepare("UPDATE memories SET embedding = ?, embedding_model = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), "m1#aaa", b.id);
|
|
553
|
+
store.db.prepare("UPDATE memories SET embedding = ?, embedding_model = ? WHERE id = ?").run(JSON.stringify([1, 2, 3, 4]), "m2#bbb", c.id);
|
|
554
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("not-json", d.id);
|
|
555
|
+
|
|
556
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
557
|
+
const res = new FakeRes();
|
|
558
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
559
|
+
assert.equal(res.statusCode, 200);
|
|
560
|
+
const data = JSON.parse(res.body);
|
|
561
|
+
|
|
562
|
+
// 无 embedder → 无当前维度可比对:unsearchable 报 0、retrievable 报 null。
|
|
563
|
+
assert.equal(data.vectors.unsearchable, 0);
|
|
564
|
+
assert.equal(data.vectors.retrievable, null);
|
|
565
|
+
const dims = Object.fromEntries(data.vectors.byDimension.map((e) => [e.dimension, e.count]));
|
|
566
|
+
assert.equal(dims["3"], 2);
|
|
567
|
+
assert.equal(dims["4"], 1);
|
|
568
|
+
assert.equal(dims[null], 1, "unparseable vector counted under null");
|
|
569
|
+
const models = Object.fromEntries(data.vectors.byModel.map((e) => [e.model, e.count]));
|
|
570
|
+
assert.equal(models["m1#aaa"], 1);
|
|
571
|
+
assert.equal(models["m2#bbb"], 1);
|
|
572
|
+
assert.equal(models[null], 2, "rows without provenance counted under null");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("GET /api/dsh-mneme/semantic counts stale-dimension rows as unsearchable with a matching embedder", async () => {
|
|
576
|
+
// 核心回归锁:库里掺一条错维向量,端点必须报出 unsearchable ≥ 1。
|
|
577
|
+
// 旧实现无论怎么混都只给 coverage,状态卡说不出「有向量但检索不到」。
|
|
578
|
+
const { routes, store, service } = setup({ name: "OpenAI", embed() {}, dimension: 3 });
|
|
579
|
+
const a = service.saveWithDedupe({ type: "preference", title: "同维", content: "x" }).memory;
|
|
580
|
+
const b = service.saveWithDedupe({ type: "preference", title: "错维", content: "x" }).memory;
|
|
581
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), a.id);
|
|
582
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8]), b.id);
|
|
583
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
584
|
+
const res = new FakeRes();
|
|
585
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
586
|
+
assert.equal(res.statusCode, 200);
|
|
587
|
+
const data = JSON.parse(res.body);
|
|
588
|
+
assert.equal(data.vectors.unsearchable, 1, "the 8-dim row is not searchable against a 3-dim embedder");
|
|
589
|
+
assert.equal(data.vectors.retrievable, 1, "the 3-dim row is searchable");
|
|
590
|
+
// coverage 走 semantic.vectorIndex.getStats(本测试未挂 → 0),所以「有向量的行」
|
|
591
|
+
// 用 byDimension 总和对照:两条都在「有向量」里,但只有一条可检索 —— 这正是
|
|
592
|
+
// unsearchable 必须存在的原因。
|
|
593
|
+
const embeddedRows = data.vectors.byDimension.reduce((s, e) => s + e.count, 0);
|
|
594
|
+
assert.equal(embeddedRows, 2, "both rows have vectors");
|
|
595
|
+
assert.equal(data.vectors.unsearchable, 1, "yet only one is retrievable");
|
|
596
|
+
});
|
|
597
|
+
|
|
541
598
|
// --- Bug8: llm-audit API (pagination + stats) --------------------------------
|
|
542
599
|
|
|
543
600
|
test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
|
|
@@ -686,8 +743,12 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
686
743
|
// persona 页新增 personaEnabled(人格注入顶层总闸;顶层扁平键,同上)。
|
|
687
744
|
// 计数锁钉的是「白名单键 ↔ config schema 成对」:schema 加了键而漏了白名单
|
|
688
745
|
// (或反过来)时,effective 会静默少键/多键,面板读不到也报不出来。
|
|
746
|
+
// issue #471 新增 scopeState.enabled/scopeState.enforce(政策轴作用域级可逆闸门;
|
|
747
|
+
// 两个都是嵌套对象子字段,都进 NESTED_FLAG_PATHS)。
|
|
689
748
|
// 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99
|
|
690
|
-
|
|
749
|
+
// #471 再 + 2 = 101。
|
|
750
|
+
// v0.9.3 新增 actionNudgeEnabled(M.O. 自动沉淀;顶层扁平键)= 102。
|
|
751
|
+
assert.equal(Object.keys(data.effective).length, 102);
|
|
691
752
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
692
753
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
693
754
|
assert.equal(data.effective.dreamMergeGuard, true);
|
|
@@ -695,6 +756,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
695
756
|
// issue #380:注入前判定两级开关,默认全关(默认路径零行为变化)
|
|
696
757
|
assert.equal(data.effective["preInjectGate.enabled"], false);
|
|
697
758
|
assert.equal(data.effective["preInjectGate.enforce"], false);
|
|
759
|
+
// issue #471:政策轴作用域级可逆闸门,两级开关默认全关(默认路径零行为变化)
|
|
760
|
+
assert.equal(data.effective["scopeState.enabled"], false);
|
|
761
|
+
assert.equal(data.effective["scopeState.enforce"], false);
|
|
698
762
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
699
763
|
assert.equal(data.effective.autoDreamFailureBackoff, false);
|
|
700
764
|
assert.equal(data.effective.dreamMaxTokens, 131072);
|
|
@@ -729,6 +793,10 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
729
793
|
assert.equal(data.effective.actionRunEnabled, false);
|
|
730
794
|
// 人格页(beta):注入总闸默认关,effective 必须能读到(同 #254 坑形防线)。
|
|
731
795
|
assert.equal(data.effective.personaEnabled, false);
|
|
796
|
+
// v0.9.3:M.O. 自动沉淀的第三个键。与 actionPipelineEnabled(看图)、
|
|
797
|
+
// actionRunEnabled(记账)都不是一件事——它挂 session/event 并解压会话日志,
|
|
798
|
+
// 是三者里最重的一条,所以独立开关、默认关、且进 LIGHT_MODE_OFF。
|
|
799
|
+
assert.equal(data.effective.actionNudgeEnabled, false);
|
|
732
800
|
// 新增字符串 / URL / 枚举键
|
|
733
801
|
assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
|
|
734
802
|
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
@@ -755,6 +823,47 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
755
823
|
assert.equal(data.effective.dreamCandidateMode, "window");
|
|
756
824
|
assert.equal(data.effective.dreamCandidateMax, 0);
|
|
757
825
|
assert.equal(data.effective.dreamCandidateMinSim, 0.85);
|
|
826
|
+
// #481:没有 overrides 时不存在「改了还没生效」的键,必须是空数组而不是缺字段
|
|
827
|
+
// ——面板按数组长度渲染提示条,字段缺失会让它渲染出 undefined.length 这类东西。
|
|
828
|
+
assert.ok(Array.isArray(data.pendingRestart), "pendingRestart must always be an array");
|
|
829
|
+
assert.deepEqual(data.pendingRestart, []);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test("GET /api/dsh-mneme/features reports only the overrides the frozen config has not picked up", async () => {
|
|
833
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
834
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
835
|
+
// #481 实测现场:面板显示人格总闸「已开启」,人格段却不注入——因为开关在宿主
|
|
836
|
+
// 启动时合并进 cfg 并冻结,运行中改 kv 只是持久化。pendingRestart 就是把
|
|
837
|
+
// 「显式覆盖 ≠ 冻结值」这个落差报出来,否则用户只能靠反复试错发现。
|
|
838
|
+
settings.setFeatureFlags({
|
|
839
|
+
personaEnabled: true, // 冻结值 false → 待重启
|
|
840
|
+
autoDream: false, // 冻结值 true → 待重启
|
|
841
|
+
"memoryQualityFilter.enabled": false, // 冻结值 true → 待重启
|
|
842
|
+
distillMaxChars: 24000 // 与冻结值相同 → 不该报
|
|
843
|
+
});
|
|
844
|
+
const res = new FakeRes();
|
|
845
|
+
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
846
|
+
assert.equal(res.statusCode, 200);
|
|
847
|
+
const data = JSON.parse(res.body);
|
|
848
|
+
// 比的是集合而非顺序(顺序跟白名单分组走,不是契约的一部分)。
|
|
849
|
+
assert.deepEqual([...data.pendingRestart].sort(), ["autoDream", "memoryQualityFilter.enabled", "personaEnabled"]);
|
|
850
|
+
assert.ok(!data.pendingRestart.includes("distillMaxChars"),
|
|
851
|
+
"a key whose override equals the frozen value is not pending");
|
|
852
|
+
// effective 仍然是「运行时取什么值」= overrides 优先,不受本字段影响。
|
|
853
|
+
assert.equal(data.effective.personaEnabled, true);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("GET /api/dsh-mneme/features cannot report pendingRestart without a frozen config", async () => {
|
|
857
|
+
// config 缺席(旧调用方直连、未传 cfg)时无从比较冻结值——返回空数组,不编造。
|
|
858
|
+
const { routes, settings } = setup(undefined);
|
|
859
|
+
settings.setFeatureFlags({ personaEnabled: true });
|
|
860
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
861
|
+
const res = new FakeRes();
|
|
862
|
+
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
863
|
+
assert.equal(res.statusCode, 200);
|
|
864
|
+
const data = JSON.parse(res.body);
|
|
865
|
+
assert.deepEqual(data.pendingRestart, []);
|
|
866
|
+
assert.equal(data.effective.personaEnabled, true, "effective still reports the override");
|
|
758
867
|
});
|
|
759
868
|
|
|
760
869
|
test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
|
|
@@ -792,6 +901,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
792
901
|
// NESTED_FLAG_PATHS——否则 configFlagValue 落到顶层同名键上,effective 静默缺键)。
|
|
793
902
|
"distillRecords.enabled": true,
|
|
794
903
|
"distillRecords.retentionDays": 30,
|
|
904
|
+
// #471:政策轴的两个点号键往返——白名单与 NESTED_FLAG_PATHS 成对缺一即静默缺键。
|
|
905
|
+
"scopeState.enabled": true,
|
|
906
|
+
"scopeState.enforce": true,
|
|
795
907
|
// #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返;
|
|
796
908
|
// 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。
|
|
797
909
|
sensitiveScanEnabled: true,
|
|
@@ -813,6 +925,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
813
925
|
"preInjectGate.enforce": true,
|
|
814
926
|
"distillRecords.enabled": true,
|
|
815
927
|
"distillRecords.retentionDays": 30,
|
|
928
|
+
"scopeState.enabled": true,
|
|
929
|
+
"scopeState.enforce": true,
|
|
816
930
|
sensitiveScanEnabled: true,
|
|
817
931
|
embedProvider: "local",
|
|
818
932
|
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
@@ -829,6 +943,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
829
943
|
assert.equal(data.effective["preInjectGate.enforce"], true);
|
|
830
944
|
assert.equal(data.effective["distillRecords.enabled"], true);
|
|
831
945
|
assert.equal(data.effective["distillRecords.retentionDays"], 30, "整数点号键也要读得回来");
|
|
946
|
+
// #471:政策轴两个点号键同款往返
|
|
947
|
+
assert.equal(data.effective["scopeState.enabled"], true);
|
|
948
|
+
assert.equal(data.effective["scopeState.enforce"], true);
|
|
832
949
|
assert.equal(data.effective.sensitiveScanEnabled, true);
|
|
833
950
|
assert.equal(data.effective.embedProvider, "local");
|
|
834
951
|
assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// `--flag value` 的值本身可以是 `--` 开头的字符串。真撞上的那次是 CI:
|
|
2
|
+
//
|
|
3
|
+
// not ok 846 - CLI:reclaim 默认 dry-run 报数字,--apply --vacuum 才真收
|
|
4
|
+
// ... bin/cli.mjs reclaim --url http://127.0.0.1:55423 --token <以 -- 开头的随机 token>
|
|
5
|
+
// 未配置访问 token,无法调用该命令
|
|
6
|
+
//
|
|
7
|
+
// token 由 `crypto.randomBytes(24).toString("base64url")` 生成(src/api-standalone.js),
|
|
8
|
+
// base64url 的字母表含 `-`,于是约 1/4096 的 token 头两位都是 `-` → 拼出来就是 `--…`
|
|
9
|
+
// → 旧 parseArgv 把它当新旗标、token 变 `true` → 退回环境变量/配置文件 → 全空。
|
|
10
|
+
// 也就是说这是一条**真实用户的随机性**,不是测试写法问题:一次 CI 红只是运气不好。
|
|
11
|
+
//
|
|
12
|
+
// 这个文件把解析语义钉死:值可以是任意 `--` 开头的串,而真旗标仍按旗标解析。
|
|
13
|
+
//
|
|
14
|
+
// 下面刻意不写整串字面量:那个 token 是一次性测试服务现生成的、早已随进程消失,
|
|
15
|
+
// 而一整串高熵字符留在源码里会被密钥扫描(gitleaks 的 generic-api-key 按熵报警)
|
|
16
|
+
// 当成真凭据。用拼接构造,形状一样、不触发扫描。
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { parseArgv, KNOWN_FLAGS, flagValue, flagBool } from "../bin/cli.mjs";
|
|
20
|
+
|
|
21
|
+
// 拼成 `--token` / `--` 开头的值,两个片段各自都是低熵短串。
|
|
22
|
+
const FLAG_TOKEN = "--tok" + "en";
|
|
23
|
+
const DASH_VALUE = "--" + "dash-prefixed-value";
|
|
24
|
+
|
|
25
|
+
test("CLI 解析:--token 的值以 -- 开头时仍取到值(CI 回归现场)", () => {
|
|
26
|
+
const { positionals, flags } = parseArgv([
|
|
27
|
+
"reclaim", "--url", "http://127.0.0.1:55423",
|
|
28
|
+
FLAG_TOKEN, DASH_VALUE
|
|
29
|
+
]);
|
|
30
|
+
assert.deepEqual(positionals, ["reclaim"]);
|
|
31
|
+
assert.equal(flags.token, DASH_VALUE);
|
|
32
|
+
// 这条才是当初真正失败的地方:值为 true 时 flagValue 视为「没给」。
|
|
33
|
+
assert.equal(flagValue(flags, "token"), DASH_VALUE);
|
|
34
|
+
// 旧实现会在这里多出一个键:把值当成旗标名(`dash-prefixed-value`)。
|
|
35
|
+
assert.deepEqual(Object.keys(flags).sort(), ["token", "url"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("CLI 解析:单横线开头的值同样按值处理", () => {
|
|
39
|
+
const { flags } = parseArgv(["add", "--title", "-x", "--content", "-"]);
|
|
40
|
+
assert.equal(flags.title, "-x");
|
|
41
|
+
assert.equal(flags.content, "-");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("CLI 解析:真旗标仍按旗标解析,不会被前一个旗标吞成值", () => {
|
|
45
|
+
// --limit 后面跟 --json:--json 是已注册旗标,所以 --limit 没有值(true),
|
|
46
|
+
// --json 是布尔。这是旧语义,必须保持,否则 `--limit --json` 会静默变成
|
|
47
|
+
// limit="--json"。
|
|
48
|
+
const { flags } = parseArgv(["list", "--limit", "--json"]);
|
|
49
|
+
assert.equal(flags.limit, true);
|
|
50
|
+
assert.equal(flagBool(flags, "json"), true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("CLI 解析:等号形式与 `--` 分隔符语义不变", () => {
|
|
54
|
+
assert.equal(parseArgv(["reclaim", "--token=--abc"]).flags.token, "--abc");
|
|
55
|
+
// `--` 之后全是位置参数,即使以 -- 开头。
|
|
56
|
+
assert.deepEqual(parseArgv(["search", "--", "--weird"]).positionals, ["search", "--weird"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("CLI 解析:未知的 -- 开头词当作上一个旗标的值", () => {
|
|
60
|
+
// 这是本修复的判定规则:只有 KNOWN_FLAGS 里登记过的词才算旗标。
|
|
61
|
+
const { flags } = parseArgv(["add", "--content", "--not-a-real-flag"]);
|
|
62
|
+
assert.equal(flags.content, "--not-a-real-flag");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("CLI KNOWN_FLAGS 覆盖 flagValue/flagBool 的全部实际用法", async () => {
|
|
66
|
+
// 漏登记的代价:该旗标会被当成前一个旗标的值吞掉,且没有任何报错。
|
|
67
|
+
// 所以用文本锁把 KNOWN_FLAGS 与 cli.mjs 里的调用点钉在一起——新增命令
|
|
68
|
+
// 忘登记会在这里炸,而不是等 1/4096 的用户撞上。
|
|
69
|
+
const { readFileSync } = await import("node:fs");
|
|
70
|
+
const { fileURLToPath } = await import("node:url");
|
|
71
|
+
const path = await import("node:path");
|
|
72
|
+
const cliPath = path.join(
|
|
73
|
+
path.dirname(path.dirname(fileURLToPath(import.meta.url))), "bin", "cli.mjs");
|
|
74
|
+
const src = readFileSync(cliPath, "utf8");
|
|
75
|
+
|
|
76
|
+
const used = new Set();
|
|
77
|
+
for (const m of src.matchAll(/flag(?:Value|Bool)\(\s*flags\s*,([^)]*)\)/g)) {
|
|
78
|
+
// 只认字符串字面量;`...names` 这种转发形式由调用方各自登记。
|
|
79
|
+
for (const s of m[1].matchAll(/'([^']+)'/g)) used.add(s[1].toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
assert.ok(used.size >= 15, "至少要解析出 CLI 全部取值/布尔旗标,否则这把锁失效了");
|
|
82
|
+
|
|
83
|
+
const missing = [...used].filter((k) => !KNOWN_FLAGS.has(k));
|
|
84
|
+
assert.deepEqual(missing, [],
|
|
85
|
+
"以下旗标在用但没登记进 KNOWN_FLAGS,会被前一个 --flag 吞成值:" + missing.join(", "));
|
|
86
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -1051,6 +1051,47 @@ test("settings card folds everything beyond the first screen behind All settings
|
|
|
1051
1051
|
"the default/changed badge must be decided from overrides, not a new backend field");
|
|
1052
1052
|
});
|
|
1053
1053
|
|
|
1054
|
+
// #481:面板开关「改了不生效」的体验坑。开关在宿主启动时合并进冻结 cfg,
|
|
1055
|
+
// 此后面板改 kv 只是持久化——用户看到「已改」就以为生效了,实测(人格总闸)
|
|
1056
|
+
// 因此白白排查了一轮。这条锁三件事:①第三态徽标由后端的 pendingRestart 名单
|
|
1057
|
+
// 决定(前端算不出来:overrides 只说明「改了」,不说明「用没用上」);
|
|
1058
|
+
// ②卡片顶部有整卡提示行;③中英文案与语义色齐备,且「待重启」不能靠
|
|
1059
|
+
// opacity 之类的弱化手段与「已改」区分(弱化在浅色主题下会掉到看不清)。
|
|
1060
|
+
test("feature switches expose a third badge state for saved-but-not-live overrides (#481)", () => {
|
|
1061
|
+
// ① 判定来源必须是后端字段,而不是前端自己比 overrides。
|
|
1062
|
+
assert.ok(/const pending = new Set\(\(state && state\.pendingRestart\) \|\| \[\]\)/.test(clientSource),
|
|
1063
|
+
"the pending set must come from the backend pendingRestart list");
|
|
1064
|
+
assert.ok(/pending\.has\(k\)[\s\S]{0,240}?mneme-badge--pending/.test(clientSource),
|
|
1065
|
+
"flagTag must return the pending badge before falling back to default/changed");
|
|
1066
|
+
// 后端字段缺失(旧宿主)时 pending 为空集,面板退回两态——不能因此崩掉。
|
|
1067
|
+
assert.ok(/\(state && state\.pendingRestart\) \|\| \[\]/.test(clientSource),
|
|
1068
|
+
"a missing pendingRestart must degrade to an empty set, not throw");
|
|
1069
|
+
|
|
1070
|
+
// ② 整卡提示行:只在有待重启项时出现,位置在说明之后、错误行之前。
|
|
1071
|
+
const noticeAt = clientSource.indexOf('t("memory.features.pendingRestartNotice")');
|
|
1072
|
+
const descAt = clientSource.indexOf('h("div", { className: "mneme-set-desc" }, t("memory.features.desc"))');
|
|
1073
|
+
assert.ok(noticeAt > descAt && descAt >= 0, "the notice row must sit after the card description");
|
|
1074
|
+
assert.ok(/pending\.size > 0 && h\("div", \{ className: "mneme-pending" \}/.test(clientSource),
|
|
1075
|
+
"the notice row must render only when something is actually pending");
|
|
1076
|
+
|
|
1077
|
+
// ③ 双语齐备(各出现两次:zh 表 + en 表)。
|
|
1078
|
+
for (const key of ["memory.features.tagPending", "memory.features.tagPending.hint",
|
|
1079
|
+
"memory.features.pendingRestartNotice"]) {
|
|
1080
|
+
const occurrences = clientSource.split("\"" + key + "\"").length - 1;
|
|
1081
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ④ 样式走语义色层(AGENTS.md 第 9 条),且不靠透明度弱化区分状态。
|
|
1085
|
+
assert.ok(/\.mneme-badge--pending\{[^}]*var\(--mneme-warn\)[^}]*\}/.test(clientSource),
|
|
1086
|
+
"the pending badge must use the --mneme-warn semantic colour");
|
|
1087
|
+
assert.ok(!/\.mneme-badge--pending\{[^}]*opacity/.test(clientSource),
|
|
1088
|
+
"the pending badge must not rely on opacity to distinguish itself from changed");
|
|
1089
|
+
assert.ok(/\.mneme-pending\{[^}]*var\(--mneme-warn\)[^}]*\}/.test(clientSource),
|
|
1090
|
+
"the notice row must use the --mneme-warn semantic colour");
|
|
1091
|
+
assert.ok(/\.mneme-pending-dot\{[^}]*background:currentColor/.test(clientSource),
|
|
1092
|
+
"the notice dot must inherit currentColor so the colour has one source");
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1054
1095
|
// v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
|
|
1055
1096
|
// 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
|
|
1056
1097
|
test("conflict queue ships a central review surface on the status tab", () => {
|
|
@@ -1665,8 +1706,8 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
1665
1706
|
"distillRecords.enabled must be an item of the core group, not merely referenced elsewhere"
|
|
1666
1707
|
);
|
|
1667
1708
|
assert.ok(
|
|
1668
|
-
/const FEATURE_NUMBERS = \["distillRecords\.retentionDays"\]/.test(clientSource),
|
|
1669
|
-
"retentionDays must be in the numeric key list"
|
|
1709
|
+
/const FEATURE_NUMBERS = \["distillRecords\.retentionDays", "dreamMaxTokens", "sleepMaxTokens"\]/.test(clientSource),
|
|
1710
|
+
"retentionDays must be in the numeric key list (dreamMaxTokens/sleepMaxTokens joined by #478)"
|
|
1670
1711
|
);
|
|
1671
1712
|
assert.ok(
|
|
1672
1713
|
/for \(const key of FEATURE_NUMBERS\) numDraft\[key\] = String\(e\[key\] \?\? ""\);/.test(clientSource)
|
|
@@ -1694,7 +1735,12 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
1694
1735
|
"memory.features.distillRecords.enabled",
|
|
1695
1736
|
"memory.features.distillRecords.enabled.hint",
|
|
1696
1737
|
"memory.features.distillRecords.retentionDays",
|
|
1697
|
-
"memory.features.distillRecords.retentionDays.hint"
|
|
1738
|
+
"memory.features.distillRecords.retentionDays.hint",
|
|
1739
|
+
// #478:面板新暴露的两个 max_tokens 预算键,label 与 hint 缺一即哑行
|
|
1740
|
+
"memory.features.dreamMaxTokens",
|
|
1741
|
+
"memory.features.dreamMaxTokens.hint",
|
|
1742
|
+
"memory.features.sleepMaxTokens",
|
|
1743
|
+
"memory.features.sleepMaxTokens.hint"
|
|
1698
1744
|
]) {
|
|
1699
1745
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1700
1746
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
@@ -1880,13 +1926,51 @@ test("M.O. empty-state, list and scope copy exist in both languages", () => {
|
|
|
1880
1926
|
"memory.actions.scope.project",
|
|
1881
1927
|
"memory.actions.scope.global",
|
|
1882
1928
|
"memory.actions.scope.none",
|
|
1883
|
-
"memory.actions.broken"
|
|
1929
|
+
"memory.actions.broken",
|
|
1930
|
+
// 手动沉淀的全部回执:这八句里缺任何一句,按钮点下去就是一行 key 裸奔,
|
|
1931
|
+
// 而那恰好是「点了没反应」的观感来源。
|
|
1932
|
+
"memory.actions.distill.action",
|
|
1933
|
+
"memory.actions.distill.busy",
|
|
1934
|
+
"memory.actions.distill.hint",
|
|
1935
|
+
"memory.actions.distill.noProject",
|
|
1936
|
+
"memory.actions.distill.saved",
|
|
1937
|
+
"memory.actions.distill.none",
|
|
1938
|
+
"memory.actions.distill.noSessions",
|
|
1939
|
+
"memory.actions.distill.unreadable",
|
|
1940
|
+
"memory.actions.distill.failed"
|
|
1884
1941
|
]) {
|
|
1885
1942
|
const hits = (clientSource.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) || []).length;
|
|
1886
1943
|
assert.equal(hits, 2, `${key} must exist in both zh and en (got ${hits})`);
|
|
1887
1944
|
}
|
|
1888
1945
|
});
|
|
1889
1946
|
|
|
1947
|
+
// 手动沉淀按钮:面板上「立即跑一次」的唯一入口。锁三件事,每件都对应一种会静默坏掉的情形:
|
|
1948
|
+
// ① 它必须打 POST 且带上项目根——GET 是预览、不写盘,打成 GET 就成了「点了永远 0 条」;
|
|
1949
|
+
// ② materialize 不许出现:按钮只产提议,落草稿是另一个决定;
|
|
1950
|
+
// ③ 没有 project 时必须禁用——服务端只会回 400,点了必然失败的按钮比没有按钮更糟。
|
|
1951
|
+
test("M.O. pane offers a manual distill that posts the project root", () => {
|
|
1952
|
+
const bodyOf = (name) => {
|
|
1953
|
+
const start = clientSource.indexOf(`function ${name}(`);
|
|
1954
|
+
assert.ok(start > -1, `${name} must exist`);
|
|
1955
|
+
const next = clientSource.slice(start + 1).indexOf("\n function ");
|
|
1956
|
+
return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
|
|
1957
|
+
};
|
|
1958
|
+
const bar = bodyOf("PipelineDistillButton");
|
|
1959
|
+
assert.ok(bar.includes('"/api/dsh-mneme/nudge?project="'), "the button must hit the nudge route");
|
|
1960
|
+
assert.ok(bar.includes("encodeURIComponent(project)"), "the project root must travel with the request");
|
|
1961
|
+
assert.ok(bar.includes('method: "POST"'), "a preview GET writes nothing — the button must POST");
|
|
1962
|
+
assert.ok(!bar.includes("materialize"), "the button must never materialize a draft");
|
|
1963
|
+
assert.ok(bar.includes("!project"), "the button must be disabled when the project is unknown");
|
|
1964
|
+
assert.ok(bar.includes("503"), "an unreadable session log must be told apart from an empty result");
|
|
1965
|
+
// 座位:按钮挂在清单头上,且清单要拿到 project——漏传的话按钮永远禁用,
|
|
1966
|
+
// 界面上看起来只是「这个功能没做好」。
|
|
1967
|
+
assert.ok(bodyOf("PipelineWorkflowList").includes("PipelineDistillButton"), "the control belongs in the list header");
|
|
1968
|
+
assert.ok(
|
|
1969
|
+
bodyOf("PipelineBrowser").includes("activeId, onOpen: setPicked, project }"),
|
|
1970
|
+
"the browser must hand the project root down to the list"
|
|
1971
|
+
);
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1890
1974
|
// 画布能放大缩小。锁的重点不是「有没有缩放」,而是滚轮那一条必须**自己挂非被动监听**:
|
|
1891
1975
|
// React 的 onWheel 挂在 root 上且是 passive 的,preventDefault 会被浏览器忽略,
|
|
1892
1976
|
// 于是 Ctrl+滚轮既缩放画布又缩放整页,谁也没拦住——这个错本地测不出来,只在真浏览器里响。
|
|
@@ -2054,6 +2138,21 @@ test("persona card is multi-card with visible field labels and save-failure erro
|
|
|
2054
2138
|
assert.ok(labelBlock, "persona field labels must be visible (featnumlabel class), not aria-only");
|
|
2055
2139
|
// 保存失败提示:personaError 状态有渲染路径(非仅 setState)
|
|
2056
2140
|
assert.ok(clientSource.includes("t(\"memory.settings.personaSaveFailed\")"), "save failure must render an error line");
|
|
2141
|
+
// 卡删除 × 常显回归锁 v2(#457 内联覆写被 #459 回滚的教训):× 不得复用
|
|
2142
|
+
// mneme-set-del(opacity:0 仅在 .mneme-set-row:hover 解除,卡标签不在该容器
|
|
2143
|
+
// → 永远不可见),必须用独立类 mneme-persona-del 且该类常显(opacity:1)。
|
|
2144
|
+
assert.ok(
|
|
2145
|
+
/className: "mneme-persona-del"/.test(clientSource),
|
|
2146
|
+
"persona card delete × must use the dedicated always-visible class"
|
|
2147
|
+
);
|
|
2148
|
+
const delStyle = clientSource.match(/\.mneme-persona-del\{[^}]*\}/);
|
|
2149
|
+
assert.ok(delStyle, "mneme-persona-del style rule must exist");
|
|
2150
|
+
assert.ok(!/opacity:0/.test(delStyle[0]), "mneme-persona-del must not be transparent");
|
|
2151
|
+
// 不许再把 mneme-set-del 挂回卡删除按钮(防 #457 式修法回归);直接锁按钮
|
|
2152
|
+
// 的 className 值(onClick 前最近的 className 属性,注释文字不参与)。
|
|
2153
|
+
const delBtn = clientSource.match(/className: "([^"]+)"[\s\S]{0,300}?onClick: \(\) => removePersonaCard\(id\)/);
|
|
2154
|
+
assert.ok(delBtn, "card delete button wiring must exist");
|
|
2155
|
+
assert.strictEqual(delBtn[1], "mneme-persona-del", "card delete × must use mneme-persona-del, not mneme-set-del");
|
|
2057
2156
|
});
|
|
2058
2157
|
|
|
2059
2158
|
// 回归锁(复验实测踩中):面板「检测更新」的状态名必须与后端 classify
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
|
|
6
|
+
// ============================================================ distill-tx-embed-gap
|
|
7
|
+
// 回归锁:此前 scheduleEmbed 在 txDepth>0 时首行直接 return(注释写 deferred,
|
|
8
|
+
// 实际 transaction() 从不补跑)——与 #372 的实体抽取是同一个 bug 模板,只是受害者
|
|
9
|
+
// 换成了嵌入:自动蒸馏(summarize 全部走 service.transaction)写入的 session:* 行
|
|
10
|
+
// 100% 拿不到向量,语义检索对这批行永远失明。照 #372 的修复模板改为入队、提交后
|
|
11
|
+
// 补跑、回滚截断。五把锁与 test/entities.test.js 的 #372 锁一一对应,锁的是
|
|
12
|
+
// 「commit 才补跑、回滚绝不补跑」这个契约本身——任何一侧改回丢弃或回滚也补跑,
|
|
13
|
+
// 这里当场红。
|
|
14
|
+
|
|
15
|
+
function makeService(config = {}) {
|
|
16
|
+
const store = createStore(":memory:");
|
|
17
|
+
const service = createService({ store, mirror: null, config });
|
|
18
|
+
return { store, service };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 8 维定值向量;schedule 分支同步写回(语义同真实 embedder:调度即含写回),
|
|
22
|
+
// 让整条链路确定性、无异步漂移。embedSingle 仅供 ready-gate/embedSingle 分支
|
|
23
|
+
// 的调用方使用,本文件的锁都走 schedule 分支。
|
|
24
|
+
function makeEmbedder(store, embedded) {
|
|
25
|
+
return {
|
|
26
|
+
schedule(memory) {
|
|
27
|
+
embedded.push(memory.id);
|
|
28
|
+
store.setEmbedding(memory.id, new Array(8).fill(0.5), "fake-model-hash");
|
|
29
|
+
},
|
|
30
|
+
embedSingle: async () => new Array(8).fill(0.5),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("distill-tx: embedding queued inside transaction is written exactly once after commit", () => {
|
|
35
|
+
const { store, service } = makeService();
|
|
36
|
+
const embedded = [];
|
|
37
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
38
|
+
const r = service.transaction(() =>
|
|
39
|
+
service.saveWithDedupe({ type: "preference", title: "事务内创建", content: "x" })
|
|
40
|
+
);
|
|
41
|
+
const id = r.memory.id;
|
|
42
|
+
// 提交后补跑恰一次:向量非空、维度等于 embedder 输出(8 维)
|
|
43
|
+
assert.deepEqual(embedded, [id], "drain fires exactly once, after commit");
|
|
44
|
+
const row = store.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
|
|
45
|
+
const vec = JSON.parse(row.embedding);
|
|
46
|
+
assert.equal(vec.length, 8, "vector dimension equals embedder output");
|
|
47
|
+
store.close();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("distill-tx: nothing schedules while txDepth > 0 (queued, not dropped, not immediate)", () => {
|
|
51
|
+
const { store, service } = makeService();
|
|
52
|
+
const embedded = [];
|
|
53
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
54
|
+
service.transaction(() => {
|
|
55
|
+
service.saveWithDedupe({ type: "preference", title: "事务内观察", content: "x" });
|
|
56
|
+
// 事务内此刻不得触发(排进队列而不是丢弃,也不是立即跑)
|
|
57
|
+
assert.equal(embedded.length, 0, "no embedding while txDepth > 0");
|
|
58
|
+
});
|
|
59
|
+
assert.equal(embedded.length, 1, "queued entry drains after commit");
|
|
60
|
+
store.close();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("distill-tx: rolled-back transaction never embeds rolled-back rows", () => {
|
|
64
|
+
const { store, service } = makeService();
|
|
65
|
+
const embedded = [];
|
|
66
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
67
|
+
assert.throws(() => {
|
|
68
|
+
service.transaction(() => {
|
|
69
|
+
service.saveWithDedupe({ type: "preference", title: "会被回滚", content: "x" });
|
|
70
|
+
throw new Error("boom");
|
|
71
|
+
});
|
|
72
|
+
}, /boom/);
|
|
73
|
+
assert.equal(embedded.length, 0, "no embedding against rolled-back rows");
|
|
74
|
+
assert.equal(store.count(), 0, "row itself rolled back");
|
|
75
|
+
store.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 与 #372 复审补锁同构:内层回滚截断只丢内层条目,外层提交 drain 外层条目。
|
|
79
|
+
test("distill-tx: inner rollback drops only inner entries; outer commit drains the rest", () => {
|
|
80
|
+
const { store, service } = makeService();
|
|
81
|
+
const embedded = [];
|
|
82
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
83
|
+
const ids = [];
|
|
84
|
+
service.transaction(() => {
|
|
85
|
+
ids.push(service.saveWithDedupe({ type: "preference", title: "外层A", content: "x" }).memory.id);
|
|
86
|
+
try {
|
|
87
|
+
service.transaction(() => {
|
|
88
|
+
service.saveWithDedupe({ type: "preference", title: "内层回滚", content: "x" });
|
|
89
|
+
throw new Error("inner boom");
|
|
90
|
+
});
|
|
91
|
+
} catch { /* 内层回滚,外层继续 */ }
|
|
92
|
+
ids.push(service.saveWithDedupe({ type: "preference", title: "外层B", content: "x" }).memory.id);
|
|
93
|
+
});
|
|
94
|
+
assert.deepEqual(embedded.sort(), ids.sort(), "inner rolled-back row never embedded; outer rows drain once");
|
|
95
|
+
assert.equal(store.count(), 2);
|
|
96
|
+
store.close();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("distill-tx: no unbounded cap — large transaction drains every entry", () => {
|
|
100
|
+
const { store, service } = makeService();
|
|
101
|
+
const embedded = [];
|
|
102
|
+
service.setEmbedder(makeEmbedder(store, embedded));
|
|
103
|
+
service.transaction(() => {
|
|
104
|
+
for (let i = 0; i < 150; i++) {
|
|
105
|
+
service.saveWithDedupe({ type: "preference", title: `批量${i}`, content: "x" });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
assert.equal(embedded.length, 150, "no silent drop at any queue cap");
|
|
109
|
+
store.close();
|
|
110
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { withMaxTokensFallback, parseMaxTokensCap, MAX_TOKENS_REJECT_RE } from "../src/dream.js";
|
|
4
|
+
|
|
5
|
+
// Issue #478:dreamMaxTokens 默认 131072 原样发给 OpenAI 兼容网关,很多网关硬
|
|
6
|
+
// 上限更小(实测 dream_runs 180 连败的报错:"max_tokens 不能超过 65536"),
|
|
7
|
+
// 400 直接拒、推理没开始。本套锁两件事:上限提取认得出显式上限、认不出时不猜;
|
|
8
|
+
// withMaxTokensFallback 在 stream(result===undefined + getStreamError)与
|
|
9
|
+
// throw 两条路径上都按提取到的上限降级重试恰一次,AbortError 永不被降级吞掉。
|
|
10
|
+
|
|
11
|
+
function makeLogger() {
|
|
12
|
+
const warns = [];
|
|
13
|
+
return { warns, logger: { warn: (...a) => warns.push(a.join(" ")), info() {}, error() {} } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("parseMaxTokensCap: 显式上限认得出(中/英/符号三种说法)", () => {
|
|
17
|
+
assert.equal(parseMaxTokensCap("max_tokens 不能超过 65536"), 65536);
|
|
18
|
+
assert.equal(parseMaxTokensCap("Maximum value is 65536"), 65536);
|
|
19
|
+
assert.equal(parseMaxTokensCap("max_tokens must be at most 32768"), 32768);
|
|
20
|
+
assert.equal(parseMaxTokensCap("max_tokens <= 8192"), 8192);
|
|
21
|
+
assert.equal(parseMaxTokensCap("max_tokens 不得超过 4096"), 4096);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("parseMaxTokensCap: 认不出显式上限时返回 null,绝不猜", () => {
|
|
25
|
+
// 只有请求值、没有上限值——把 131072 误当上限会让重试原样再炸一次
|
|
26
|
+
assert.equal(parseMaxTokensCap("max_tokens 131072 is too large for this model"), null);
|
|
27
|
+
assert.equal(parseMaxTokensCap("bad request"), null);
|
|
28
|
+
assert.equal(parseMaxTokensCap(""), null);
|
|
29
|
+
assert.equal(parseMaxTokensCap(null), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("MAX_TOKENS_REJECT_RE: 命中 max_tokens/maxTokens 两种写法,不误伤无关报错", () => {
|
|
33
|
+
assert.ok(MAX_TOKENS_REJECT_RE.test("max_tokens 不能超过 65536"));
|
|
34
|
+
assert.ok(MAX_TOKENS_REJECT_RE.test("maxTokens exceed limit"));
|
|
35
|
+
assert.ok(!MAX_TOKENS_REJECT_RE.test("reasoning effort unsupported"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("withMaxTokensFallback: stream 路径(undefined + getStreamError)按上限降级重试恰一次", async () => {
|
|
39
|
+
const { logger, warns } = makeLogger();
|
|
40
|
+
const ctx = { logger };
|
|
41
|
+
const calls = [];
|
|
42
|
+
const attempt = async (tokens) => {
|
|
43
|
+
calls.push(tokens);
|
|
44
|
+
// 第一次发大值 → 网关拒收折叠成 undefined;重试用 65536 → 成功
|
|
45
|
+
return tokens > 65536 ? undefined : `ok@${tokens}`;
|
|
46
|
+
};
|
|
47
|
+
const out = await withMaxTokensFallback(ctx, 131072, attempt, () =>
|
|
48
|
+
"400 max_tokens 不能超过 65536");
|
|
49
|
+
assert.equal(out, "ok@65536");
|
|
50
|
+
assert.deepEqual(calls, [131072, 65536], "大值先发,拒收后按上限重试");
|
|
51
|
+
assert.equal(warns.length, 1, "降级必须留痕");
|
|
52
|
+
assert.match(warns[0], /131072.*65536/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("withMaxTokensFallback: throw 路径同样降级,AbortError 永远直通", async () => {
|
|
56
|
+
const { logger } = makeLogger();
|
|
57
|
+
const ctx = { logger };
|
|
58
|
+
// throw 路径:第一次抛 400,重试成功
|
|
59
|
+
const calls = [];
|
|
60
|
+
const out = await withMaxTokensFallback(ctx, 131072, async (tokens) => {
|
|
61
|
+
calls.push(tokens);
|
|
62
|
+
if (tokens > 65536) throw new Error("invalid max_tokens: max_tokens 不能超过 65536");
|
|
63
|
+
return `ok@${tokens}`;
|
|
64
|
+
}, () => "");
|
|
65
|
+
assert.equal(out, "ok@65536");
|
|
66
|
+
assert.deepEqual(calls, [131072, 65536]);
|
|
67
|
+
|
|
68
|
+
// AbortError:即使消息里带 max_tokens 字样也绝不触发重试(取消意图不可吞)
|
|
69
|
+
const abort = new Error("max_tokens rejected during dispose");
|
|
70
|
+
abort.name = "AbortError";
|
|
71
|
+
const abCalls = [];
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
withMaxTokensFallback(ctx, 131072, async (tokens) => { abCalls.push(tokens); throw abort; }, () => ""),
|
|
74
|
+
/dispose/
|
|
75
|
+
);
|
|
76
|
+
assert.deepEqual(abCalls, [131072], "AbortError 不重试");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("withMaxTokensFallback: 无上限可提取或上限不小于当前值时不重试", async () => {
|
|
80
|
+
const { logger } = makeLogger();
|
|
81
|
+
const ctx = { logger };
|
|
82
|
+
// 报错只有请求值没有上限 → 不猜,原样 undefined 返回
|
|
83
|
+
let calls = 0;
|
|
84
|
+
const out1 = await withMaxTokensFallback(ctx, 131072, async () => { calls++; return undefined; }, () =>
|
|
85
|
+
"max_tokens 131072 is too large for this model");
|
|
86
|
+
assert.equal(out1, undefined);
|
|
87
|
+
assert.equal(calls, 1, "认不出上限就不重试");
|
|
88
|
+
|
|
89
|
+
// 上限 >= 当前值(报错自相矛盾)→ 不重试,throw 路径原样抛
|
|
90
|
+
const boom = new Error("max_tokens 不能超过 131072");
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
withMaxTokensFallback(ctx, 131072, async () => { throw boom; }, () => ""),
|
|
93
|
+
(e) => e === boom,
|
|
94
|
+
"cap >= maxTokens 时不重试且原样抛"
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("withMaxTokensFallback: 无关报错不触发重试(stream 与 throw 双路径)", async () => {
|
|
99
|
+
const { logger } = makeLogger();
|
|
100
|
+
const ctx = { logger };
|
|
101
|
+
let calls = 0;
|
|
102
|
+
const out = await withMaxTokensFallback(ctx, 131072, async () => { calls++; return undefined; }, () =>
|
|
103
|
+
"reasoning effort UNSUPPORTED_REASONING_EFFORT");
|
|
104
|
+
assert.equal(out, undefined);
|
|
105
|
+
assert.equal(calls, 1);
|
|
106
|
+
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
withMaxTokensFallback(ctx, 131072, async () => { throw new Error("quota exhausted"); }, () => ""),
|
|
109
|
+
/quota exhausted/
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// 接线锁:单测绿不代表真接上了。#478 的第一次实现只挂了 dream 侧三个点,
|
|
114
|
+
// sleep.js 两处仍原样发 config.sleepMaxTokens,而面板 hint 已对外承诺
|
|
115
|
+
// 「超上限同样自动降级」——这种「函数有、没人用」的缺口只靠行为测试抓不到。
|
|
116
|
+
test("接线锁:dreamMaxTokens/sleepMaxTokens 的五个使用点全部包在 withMaxTokensFallback 里", async () => {
|
|
117
|
+
const { readFile } = await import("node:fs/promises");
|
|
118
|
+
const srcDir = new URL("../src/", import.meta.url);
|
|
119
|
+
const files = { "dream.js": await readFile(new URL("dream.js", srcDir), "utf8"), "dream/sleep.js": await readFile(new URL("dream/sleep.js", srcDir), "utf8") };
|
|
120
|
+
for (const [name, source] of Object.entries(files)) {
|
|
121
|
+
// 任何一处还把 config.dreamMaxTokens/sleepMaxTokens 直接当 maxTokens 发,就是漏接线。
|
|
122
|
+
assert.ok(
|
|
123
|
+
!/maxTokens:\s*config\.(dream|sleep)MaxTokens/.test(source),
|
|
124
|
+
`${name}: still passes config.*MaxTokens straight into maxTokens — wrap it in withMaxTokensFallback`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
// 五个调用点:dream 三条(叙述/巩固/摘要)+ sleep 两条(冲突/模式)。
|
|
128
|
+
const dreamHits = files["dream.js"].match(/withMaxTokensFallback\(ctx, config\.dreamMaxTokens/g) ?? [];
|
|
129
|
+
const sleepHits = files["dream/sleep.js"].match(/withMaxTokensFallback\(ctx, config\.sleepMaxTokens/g) ?? [];
|
|
130
|
+
assert.equal(dreamHits.length, 3, `dream.js must wrap all 3 dreamMaxTokens sites (got ${dreamHits.length})`);
|
|
131
|
+
assert.equal(sleepHits.length, 2, `dream/sleep.js must wrap both sleepMaxTokens sites (got ${sleepHits.length})`);
|
|
132
|
+
});
|