@modusensus/dsh-mneme 0.9.0 → 0.9.2
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 +82 -19
- package/bin/cli.mjs +69 -9
- package/docs/CONFIGURATION.md +48 -0
- package/docs/SEMANTIC.md +3 -3
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +339 -59
- package/lib/candidates.js +454 -454
- package/lib/client.js +669 -256
- package/lib/config.js +50 -2
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +153 -22
- package/lib/index.js +32 -0
- package/lib/inject.js +34 -3
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +128 -7
- package/lib/scope-state.js +277 -0
- package/lib/semantic.js +66 -10
- package/lib/service.js +80 -3
- package/lib/settings.js +139 -1
- package/lib/store.js +216 -3
- package/lib/summarize.js +2 -3
- package/lib/tools.js +71 -0
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +339 -59
- package/src/candidates.js +454 -454
- package/src/config.js +50 -2
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +153 -22
- package/src/index.js +32 -0
- package/src/inject.js +34 -3
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +128 -7
- package/src/scope-state.js +277 -0
- package/src/semantic.js +66 -10
- package/src/service.js +80 -3
- package/src/settings.js +139 -1
- package/src/store.js +216 -3
- package/src/summarize.js +2 -3
- package/src/tools.js +71 -0
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +210 -1
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +216 -6
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/lib-smoke.test.js +20 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +137 -6
- package/test/readme-claims.test.js +68 -0
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +16 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-state.test.js +481 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
- package/test/workflow-guards.test.js +156 -0
|
@@ -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
|
@@ -1020,10 +1020,14 @@ test("scope isolation ships panel switches and provenance surfaces (A4)", () =>
|
|
|
1020
1020
|
// 默认值),面板自判、不加后端字段;④分组与折叠键中英成对。验收口径:首屏
|
|
1021
1021
|
// 可点项 = 3 总开关 + autoInject 的 2 个子开关 + scope 2 开关 + 「全部设置」
|
|
1022
1022
|
// 按钮 = 8(≤ 8),由 ①② 的组成正则共同钉死,不另数 DOM。
|
|
1023
|
+
// v0.9.0 复验修订:首屏加 group.mo(M.O. 独立组),可点项预算 8→9。这不是
|
|
1024
|
+
// 悄悄放宽——是产品主人点名「别藏在里面」:M.O. 独立成组却仍折在「全部
|
|
1025
|
+
// 设置」后等于换个地方继续藏。≤8 原为 #427 自设软预算,owner 豁免一项。
|
|
1023
1026
|
test("settings card folds everything beyond the first screen behind All settings (issue #427)", () => {
|
|
1024
|
-
// ①
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
+
// ① 首屏层组成:三组都要在(basic 总开关 / mo 座位闸门 / scope 必答题),
|
|
1028
|
+
// 且 basic 的成员是从 core/dream 移走的。
|
|
1029
|
+
assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.mo", "group\.scope"\];/.test(clientSource),
|
|
1030
|
+
"the first screen must be exactly the basic, mo and scope groups");
|
|
1027
1031
|
assert.ok(/key: "group\.basic", items: \["autoInject", "autoSummarize", "autoDream"\]/.test(clientSource),
|
|
1028
1032
|
"group.basic must carry exactly the three master switches");
|
|
1029
1033
|
assert.ok(!/key: "group\.core", items: \["autoInject/.test(clientSource) && !/key: "group\.dream", items: \["autoDream/.test(clientSource),
|
|
@@ -1047,6 +1051,47 @@ test("settings card folds everything beyond the first screen behind All settings
|
|
|
1047
1051
|
"the default/changed badge must be decided from overrides, not a new backend field");
|
|
1048
1052
|
});
|
|
1049
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
|
+
|
|
1050
1095
|
// v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
|
|
1051
1096
|
// 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
|
|
1052
1097
|
test("conflict queue ships a central review surface on the status tab", () => {
|
|
@@ -1661,8 +1706,8 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
1661
1706
|
"distillRecords.enabled must be an item of the core group, not merely referenced elsewhere"
|
|
1662
1707
|
);
|
|
1663
1708
|
assert.ok(
|
|
1664
|
-
/const FEATURE_NUMBERS = \["distillRecords\.retentionDays"\]/.test(clientSource),
|
|
1665
|
-
"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)"
|
|
1666
1711
|
);
|
|
1667
1712
|
assert.ok(
|
|
1668
1713
|
/for \(const key of FEATURE_NUMBERS\) numDraft\[key\] = String\(e\[key\] \?\? ""\);/.test(clientSource)
|
|
@@ -1690,7 +1735,12 @@ test("distill retention switches are reachable from the panel (#399)", () => {
|
|
|
1690
1735
|
"memory.features.distillRecords.enabled",
|
|
1691
1736
|
"memory.features.distillRecords.enabled.hint",
|
|
1692
1737
|
"memory.features.distillRecords.retentionDays",
|
|
1693
|
-
"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"
|
|
1694
1744
|
]) {
|
|
1695
1745
|
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1696
1746
|
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
@@ -1939,3 +1989,163 @@ test("M.O. class names do not collide in the stylesheet", () => {
|
|
|
1939
1989
|
// 扫描量也得钉:正则一旦匹配不到东西,上面那条会静默变绿(例如样式表被整块挪走)。
|
|
1940
1990
|
assert.ok(declared.size >= 20, `the scan must actually see the pipeline stylesheet (saw ${declared.size})`);
|
|
1941
1991
|
});
|
|
1992
|
+
|
|
1993
|
+
// 回归锁(v0.9.0 复验轮沉淀):面板的两种「静默消失」假绿形状一起钉住。
|
|
1994
|
+
// ① i18n 缺定义:既有的「每键恰好两次」锁只统计**字典里存在**的键,一个只有
|
|
1995
|
+
// 用法、零字典行的键在计数里整个消失——本轮就查出 4 个这种键(zoom.value 自
|
|
1996
|
+
// 基线即断)。所以这里反向查:每一处用法都必须能找到定义行。三种用法形状:
|
|
1997
|
+
// 字面量 t("k")、模板 t(`memory.features.${g.key}`) 按 FEATURE_GROUPS 的组键
|
|
1998
|
+
// 展开、模板 t(`memory.features.${k}`) 按各 items 旗标键展开(boolRow 的
|
|
1999
|
+
// name/hint 各一路)。不做全量动态键解析——拼接形态的键在此设防范围外。
|
|
2000
|
+
// ② 图标名未注册:Icon 对查不到的名字 return null(无回退、无告警),闸门照绿
|
|
2001
|
+
// 而屏幕上图标没了。本轮 star 入口就是这么哑掉的。这里钉「每个用到的名字都在
|
|
2002
|
+
// ICON_PATHS 注册表里」+ 提取与注册表的最低数量(防正则空转的静默绿)。
|
|
2003
|
+
test("panel copy and icons must not silently vanish (missing dict lines, unregistered icon names)", () => {
|
|
2004
|
+
const defined = (key) => clientSource.includes('"' + key + '":');
|
|
2005
|
+
|
|
2006
|
+
// ①-a 字面量用法(右括号紧贴字符串,排除 "前缀" + 变量 拼接形态)
|
|
2007
|
+
const usedKeys = new Set();
|
|
2008
|
+
for (const m of clientSource.matchAll(/\bt\("([a-zA-Z0-9_.]+)"\)/g)) usedKeys.add(m[1]);
|
|
2009
|
+
assert.ok(usedKeys.size >= 300, `the literal scan must actually see the panel's t() calls (saw ${usedKeys.size})`);
|
|
2010
|
+
// ①-b 模板展开:组键 与 旗标键(label + .hint 两路)
|
|
2011
|
+
const groupKeys = [...clientSource.matchAll(/key: "(group\.[a-z]+)"/g)].map((m) => m[1]);
|
|
2012
|
+
assert.ok(groupKeys.length >= 5, `FEATURE_GROUPS must be parseable (saw ${groupKeys.length} groups)`);
|
|
2013
|
+
for (const g of groupKeys) usedKeys.add("memory.features." + g);
|
|
2014
|
+
const flagKeys = [...new Set([...clientSource.matchAll(/items: \[((?:[^\]]|\n)*)\]/g)]
|
|
2015
|
+
.flatMap((m) => [...m[1].matchAll(/"([a-zA-Z0-9_.]+)"/g)].map((x) => x[1])))];
|
|
2016
|
+
assert.ok(flagKeys.length >= 20, `group items must be parseable (saw ${flagKeys.length})`);
|
|
2017
|
+
for (const k of flagKeys) { usedKeys.add("memory.features." + k); usedKeys.add("memory.features." + k + ".hint"); }
|
|
2018
|
+
|
|
2019
|
+
const missing = [...usedKeys].filter((k) => !defined(k)).sort();
|
|
2020
|
+
assert.deepEqual(missing, [], `用而无典的 i18n 键(界面会裸奔出键名):${missing.join(", ")}`);
|
|
2021
|
+
|
|
2022
|
+
// ② 图标名:注册表形状(顶层对象、六空格缩进)+ Icon 表达式与 feedRow 首参的提取
|
|
2023
|
+
const registered = new Set([...clientSource.matchAll(/^\s{6}([a-zA-Z]+): \[\[/gm)].map((m) => m[1]));
|
|
2024
|
+
assert.ok(registered.size >= 10, `the ICON_PATHS registry must be parseable (saw ${registered.size})`);
|
|
2025
|
+
const iconNames = new Set();
|
|
2026
|
+
for (const m of clientSource.matchAll(/h\(Icon, \{ name: ([^}]+?) [,}]/g)) {
|
|
2027
|
+
const expr = m[1];
|
|
2028
|
+
const direct = expr.match(/^"([a-zA-Z]+)"$/);
|
|
2029
|
+
if (direct) { iconNames.add(direct[1]); continue; }
|
|
2030
|
+
// 只取三元分支的纯字母串;"===" 比较对象前面是运算符不是 ? : ,不会误伤
|
|
2031
|
+
for (const b of expr.matchAll(/[?:]\s*"([a-zA-Z]+)"/g)) iconNames.add(b[1]);
|
|
2032
|
+
}
|
|
2033
|
+
for (const m of clientSource.matchAll(/feedRow\(\s*"([a-zA-Z]+)"/g)) iconNames.add(m[1]);
|
|
2034
|
+
assert.ok(iconNames.size >= 8, `icon usage must be parseable (saw ${iconNames.size})`);
|
|
2035
|
+
const noIcon = [...iconNames].filter((n) => !registered.has(n)).sort();
|
|
2036
|
+
assert.deepEqual(noIcon, [], `Icon 静默 return null 的未注册名:${noIcon.join(", ")}`);
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
// 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
|
|
2040
|
+
// + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
|
|
2041
|
+
test("persona page ships settings card, master switch and i18n (beta v1)", () => {
|
|
2042
|
+
// 开关进 FEATURE_GROUPS 的 enhance 组(行为开关非座位闸门)
|
|
2043
|
+
assert.ok(
|
|
2044
|
+
/group\.enhance", items: \[(?:[^\]]|\n)*"personaEnabled"\]/.test(clientSource),
|
|
2045
|
+
"FEATURE_GROUPS group.enhance must carry personaEnabled"
|
|
2046
|
+
);
|
|
2047
|
+
// i18n 键中英双语齐备(occurrences ≥ 2 模式;label + .hint 两路由字典锁统一覆盖)
|
|
2048
|
+
for (const key of [
|
|
2049
|
+
"memory.features.personaEnabled",
|
|
2050
|
+
"memory.features.personaEnabled.hint",
|
|
2051
|
+
"memory.settings.persona",
|
|
2052
|
+
"memory.settings.personaHint",
|
|
2053
|
+
"memory.settings.personaName",
|
|
2054
|
+
"memory.settings.personaPersonality",
|
|
2055
|
+
"memory.settings.personaReplyStyle",
|
|
2056
|
+
"memory.settings.personaSave",
|
|
2057
|
+
"memory.settings.personaSaved",
|
|
2058
|
+
"memory.settings.personaEnabledToggle",
|
|
2059
|
+
"memory.settings.personaView",
|
|
2060
|
+
"memory.settings.personaViewHint",
|
|
2061
|
+
"memory.settings.personaViewEmpty"
|
|
2062
|
+
]) {
|
|
2063
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2064
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2065
|
+
}
|
|
2066
|
+
// 设置卡接线:保存走 PUT /persona、卡内开关改 enabled 即保存、只读卡渲染
|
|
2067
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/persona"'), "persona card must talk to /persona");
|
|
2068
|
+
assert.ok(clientSource.includes("method: \"PUT\""), "persona save must PUT");
|
|
2069
|
+
assert.ok(clientSource.includes("t(\"memory.settings.personaView\")"), "read-only injected-view card must exist");
|
|
2070
|
+
// 双闸门提示:hint 文案必须说明「总闸与卡内开关同时打开才生效」
|
|
2071
|
+
assert.ok(
|
|
2072
|
+
clientSource.includes("memory.features.personaEnabled.hint"),
|
|
2073
|
+
"the master-switch hint must document the two-gate semantics"
|
|
2074
|
+
);
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
// v1.2 多卡回归:卡列表 CRUD 可见、字段标签渲染为可见元素(v1.1 反馈:
|
|
2078
|
+
// aria-label 看不见)、保存失败必须冒可见错误(v1 静默 catch 吞 404 教训)。
|
|
2079
|
+
test("persona card is multi-card with visible field labels and save-failure error", () => {
|
|
2080
|
+
for (const key of [
|
|
2081
|
+
"memory.settings.personaDefaultCard",
|
|
2082
|
+
"memory.settings.personaAdd",
|
|
2083
|
+
"memory.settings.personaAddPlaceholder",
|
|
2084
|
+
"memory.settings.personaEditingDefault",
|
|
2085
|
+
"memory.settings.personaEditingFor",
|
|
2086
|
+
"memory.settings.personaDeleteCard",
|
|
2087
|
+
"memory.settings.personaSaveFailed",
|
|
2088
|
+
"memory.settings.personaEnabledOn",
|
|
2089
|
+
"memory.settings.personaEnabledOff"
|
|
2090
|
+
]) {
|
|
2091
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
2092
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
2093
|
+
}
|
|
2094
|
+
// 多卡接线:加卡/删卡/切换/保存全链函数存在
|
|
2095
|
+
for (const fn of ["addPersonaCard", "removePersonaCard", "savePersonaCards", "setPersonaEditing"]) {
|
|
2096
|
+
assert.ok(clientSource.includes(fn), `multi-card wiring must include ${fn}`);
|
|
2097
|
+
}
|
|
2098
|
+
// 可见字段标签:三个字段名的 t() 调用出现在 label 元素(mneme-featnumlabel)
|
|
2099
|
+
const labelBlock = clientSource.match(/mneme-featnumlabel[^]{0,120}personaName/);
|
|
2100
|
+
assert.ok(labelBlock, "persona field labels must be visible (featnumlabel class), not aria-only");
|
|
2101
|
+
// 保存失败提示:personaError 状态有渲染路径(非仅 setState)
|
|
2102
|
+
assert.ok(clientSource.includes("t(\"memory.settings.personaSaveFailed\")"), "save failure must render an error line");
|
|
2103
|
+
// 卡删除 × 常显回归锁 v2(#457 内联覆写被 #459 回滚的教训):× 不得复用
|
|
2104
|
+
// mneme-set-del(opacity:0 仅在 .mneme-set-row:hover 解除,卡标签不在该容器
|
|
2105
|
+
// → 永远不可见),必须用独立类 mneme-persona-del 且该类常显(opacity:1)。
|
|
2106
|
+
assert.ok(
|
|
2107
|
+
/className: "mneme-persona-del"/.test(clientSource),
|
|
2108
|
+
"persona card delete × must use the dedicated always-visible class"
|
|
2109
|
+
);
|
|
2110
|
+
const delStyle = clientSource.match(/\.mneme-persona-del\{[^}]*\}/);
|
|
2111
|
+
assert.ok(delStyle, "mneme-persona-del style rule must exist");
|
|
2112
|
+
assert.ok(!/opacity:0/.test(delStyle[0]), "mneme-persona-del must not be transparent");
|
|
2113
|
+
// 不许再把 mneme-set-del 挂回卡删除按钮(防 #457 式修法回归);直接锁按钮
|
|
2114
|
+
// 的 className 值(onClick 前最近的 className 属性,注释文字不参与)。
|
|
2115
|
+
const delBtn = clientSource.match(/className: "([^"]+)"[\s\S]{0,300}?onClick: \(\) => removePersonaCard\(id\)/);
|
|
2116
|
+
assert.ok(delBtn, "card delete button wiring must exist");
|
|
2117
|
+
assert.strictEqual(delBtn[1], "mneme-persona-del", "card delete × must use mneme-persona-del, not mneme-set-del");
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
// 回归锁(复验实测踩中):面板「检测更新」的状态名必须与后端 classify
|
|
2121
|
+
// (src/version-check.js)逐字对齐。此前前端误写 "latest"/"up_to_date"(后端
|
|
2122
|
+
// 从未返回过),检测成功也掉进 error 兜底——按钮永远报「检测失败」。
|
|
2123
|
+
// 这条缝两端各有测试(version-check.test.js 测 classify、面板测试测渲染),
|
|
2124
|
+
// 但契约本身没人管。锁三件事:①前端比较的字面量是后端真实返回值;②后端
|
|
2125
|
+
// classify 的返回集合没有前端漏接的成员;③apiFetch 的路由路径与后端注册一致。
|
|
2126
|
+
test("update-check status names match the backend classify contract verbatim", () => {
|
|
2127
|
+
const vcSource = readFileSync(join(root, "src/version-check.js"), "utf8");
|
|
2128
|
+
// ① 后端 classify 的全部返回字面量
|
|
2129
|
+
const backendStatuses = new Set(
|
|
2130
|
+
[...vcSource.matchAll(/return "([a-z-]+)";/g)].map((m) => m[1])
|
|
2131
|
+
);
|
|
2132
|
+
assert.ok(backendStatuses.has("up-to-date") && backendStatuses.has("outdated") && backendStatuses.has("ahead") && backendStatuses.has("unknown"),
|
|
2133
|
+
`classify must return the known status set (saw ${[...backendStatuses].join(", ")})`);
|
|
2134
|
+
// ② 前端 checkResultText 比较的每个状态名都必须 ∈ 后端集合(防再发明新名)
|
|
2135
|
+
const iStart = clientSource.indexOf("function checkResultText");
|
|
2136
|
+
assert.ok(iStart > 0, "checkResultText must exist");
|
|
2137
|
+
// 函数块取到下一个同级 function 声明为止:indexOf("}") 会切在首个 if 的
|
|
2138
|
+
// 右括号,比较分支被截断成 1 个(2026-10-17 实测)。
|
|
2139
|
+
const iEnd = clientSource.slice(iStart).search(/\n {6}function /);
|
|
2140
|
+
const block = clientSource.slice(iStart, iEnd > 0 ? iStart + iEnd : undefined);
|
|
2141
|
+
const compared = [...block.matchAll(/updateInfo\.status === "([a-zA-Z_-]+)"/g)].map((m) => m[1]);
|
|
2142
|
+
assert.ok(compared.length >= 2, `the panel must compare real statuses (saw ${compared.length})`);
|
|
2143
|
+
const unknown = [...new Set(compared)].filter((s) => !backendStatuses.has(s));
|
|
2144
|
+
assert.deepEqual(unknown, [], `前端比较了后端不会返回的状态名(检测成功也会显示失败):${unknown.join(", ")}`);
|
|
2145
|
+
// ③ 路由契约:前端 fetch 的路径必须与后端注册的 exact path 一致
|
|
2146
|
+
assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/version-check")'),
|
|
2147
|
+
"the panel must call the registered version-check route");
|
|
2148
|
+
const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
|
|
2149
|
+
assert.ok(apiSource.includes('path: "/api/dsh-mneme/version-check"'),
|
|
2150
|
+
"the backend must register the version-check route");
|
|
2151
|
+
});
|
|
@@ -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,65 @@
|
|
|
1
|
+
// 「CONFIGURATION.md 是全键参考」这句声明需要一条闸门,否则它会慢慢变成谎话:
|
|
2
|
+
// 2026-10 审计实测有 11 个 schema 键(graph 组 8 个、sensitiveScanEnabled、
|
|
3
|
+
// injectTimePrefix、actionPipelineEnabled)在文档与双 README 里一个字都没有,
|
|
4
|
+
// 而它们里有安全开关。锁法:从 Config({}) 的默认值深挖叶子键,要求每个键名
|
|
5
|
+
// (或它的父路径,用于 heatTypeDecay 这类整表描述)在文档里出现。
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { Config } from "../src/config.js";
|
|
12
|
+
|
|
13
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
14
|
+
const doc = readFileSync(path.join(root, "docs", "CONFIGURATION.md"), "utf8");
|
|
15
|
+
const cfgSrc = readFileSync(path.join(root, "src", "config.js"), "utf8");
|
|
16
|
+
|
|
17
|
+
function leafKeys(obj, prefix) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
20
|
+
const p = prefix ? prefix + "." + k : k;
|
|
21
|
+
if (v && typeof v === "object" && !Array.isArray(v)) { out.push(...leafKeys(v, p)); continue; }
|
|
22
|
+
out.push({ path: p, leaf: k, parent: prefix || "" });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 父路径回退是给「整表说明」用的例外,白名单写死在这里而不是「任何父路径都
|
|
28
|
+
// 放行」——否则往 writeAdmission 里加个子键,只要文档提到 writeAdmission 就过,
|
|
29
|
+
// 测试原本要防的漏键会静默复发(评审指出的漏洞)。新增例外必须显式加一行。
|
|
30
|
+
const PARENT_ONLY_OK = new Set(["heatTypeDecay"]);
|
|
31
|
+
|
|
32
|
+
test("CONFIGURATION.md 覆盖 schema 的每一个键", () => {
|
|
33
|
+
const keys = leafKeys(Config({}), "");
|
|
34
|
+
assert.ok(keys.length > 150, "键枚举失败会静默返回 0 项,先锁下限");
|
|
35
|
+
const missing = keys.filter((k) => {
|
|
36
|
+
// 完整路径优先("writeAdmission.enabled"),其次才是显式白名单里的整表父路径。
|
|
37
|
+
if (doc.includes(k.path)) return false;
|
|
38
|
+
if (k.parent && PARENT_ONLY_OK.has(k.parent) && doc.includes(k.parent)) return false;
|
|
39
|
+
// 表里写的是裸键名也认(绝大多数顶层键就是这么写的)。
|
|
40
|
+
return !doc.includes(k.leaf);
|
|
41
|
+
});
|
|
42
|
+
assert.deepEqual(missing.map((m) => m.path), []);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// lightMode 清单是有边界的一节:整份文档 grep 会让「删掉清单里的某一行」照样
|
|
46
|
+
// 通过(图谱表里还留着同名键),测试要盯的漏项就藏在那个缝里。
|
|
47
|
+
function lightSection(text) {
|
|
48
|
+
const start = text.indexOf("lightMode 强制关闭清单");
|
|
49
|
+
assert.ok(start >= 0, "文档里应有「lightMode 强制关闭清单」一节");
|
|
50
|
+
const rest = text.slice(start);
|
|
51
|
+
const end = rest.indexOf("预设只是默认值");
|
|
52
|
+
return end > 0 ? rest.slice(0, end) : rest;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test("LIGHT_MODE_OFF 的每一项都写进了文档的轻量档清单", () => {
|
|
56
|
+
// LIGHT_MODE_OFF 没有导出,只能解析源码文本;先锁解析结果的长度,防止
|
|
57
|
+
// 正则失配时这个用例变成「空数组 ⊂ 文档」的永真断言。
|
|
58
|
+
const m = cfgSrc.match(/const LIGHT_MODE_OFF = \[([\s\S]*?)\];/);
|
|
59
|
+
assert.ok(m, "LIGHT_MODE_OFF 应从 src/config.js 中解析出来");
|
|
60
|
+
const names = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
|
61
|
+
assert.ok(names.length >= 20, "解析到 " + names.length + " 项,少于预期的 21 项");
|
|
62
|
+
const section = lightSection(doc);
|
|
63
|
+
const missing = names.filter((n) => !section.includes(n));
|
|
64
|
+
assert.deepEqual(missing, []);
|
|
65
|
+
});
|
|
@@ -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
|
+
});
|