@modusensus/dsh-mneme 0.8.16 → 0.9.0
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 +16 -3
- package/assets/icon.svg +2 -2
- package/docs/CONFIGURATION.md +2 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api.js +124 -8
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +1703 -182
- package/lib/config.js +31 -5
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +49 -12
- package/lib/inject.js +6 -1
- package/lib/pipeline.js +534 -0
- package/lib/scope.js +16 -4
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +7 -1
- package/lib/store.js +55 -3
- package/lib/summarize.js +31 -0
- package/lib/tools.js +11 -2
- package/package.json +1 -1
- package/scripts/publish-gate.mjs +197 -0
- package/src/api.js +124 -8
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +31 -5
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/entities/extractor.js +4 -0
- package/src/index.js +49 -12
- package/src/inject.js +6 -1
- package/src/pipeline.js +534 -0
- package/src/scope.js +16 -4
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +7 -1
- package/src/store.js +55 -3
- package/src/summarize.js +31 -0
- package/src/tools.js +11 -2
- package/test/api.test.js +147 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/client.test.js +361 -2
- package/test/continuity.test.js +84 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/inject-recall.test.js +32 -4
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -0
- package/test/pipeline.test.js +505 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/release-workflow.test.js +77 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/serve.test.js +1 -1
- package/test/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
package/test/client.test.js
CHANGED
|
@@ -839,8 +839,9 @@ test("stale route values are marked and explained at the row level (issue #191)"
|
|
|
839
839
|
"the en stale hint must cover provider-absence too, not only the model case"
|
|
840
840
|
);
|
|
841
841
|
// 4. issue #215:三条路由各自透传自己的档位键——routeSelects 按路由行
|
|
842
|
-
//
|
|
843
|
-
//
|
|
842
|
+
// 传入键名、从生效配置取值。(曾在此断言「sleep 无回退重试,测试是事前
|
|
843
|
+
// 唯一的暴露口」:该前提自 939fbfc 起就不成立——sleep/梦/蒸馏都走
|
|
844
|
+
// withEffortFallback,档位被拒会丢字段重试,测试从不是唯一暴露口。)
|
|
844
845
|
assert.ok(
|
|
845
846
|
/const routeSelects = \(providerKey, modelKey, testState, setTestState, effortKey\) => \{/.test(clientSource)
|
|
846
847
|
&& /const effort = effortKey \? \(eff\[effortKey\] \|\| ""\) : ""/.test(clientSource),
|
|
@@ -860,6 +861,31 @@ test("stale route values are marked and explained at the row level (issue #191)"
|
|
|
860
861
|
);
|
|
861
862
|
});
|
|
862
863
|
|
|
864
|
+
// 探针降级的如实呈现(issue #215 后续):后端档位被拒时不再 502,而是丢字段
|
|
865
|
+
// 重试成功 + effortDropped:true。面板必须把「连通了、但不是按你配的档位」讲出来
|
|
866
|
+
// ——静默成功和静默失败一样有害:前者让人以为档位生效,后者把人赶去换 Provider。
|
|
867
|
+
test("panel surfaces a downgraded connectivity test instead of a silent success or a red failure", () => {
|
|
868
|
+
// 1. 只有后端明确置位才算降级(旧后端无字段 → 不误报)
|
|
869
|
+
assert.ok(
|
|
870
|
+
/dropped: j\.effortDropped === true/.test(clientSource),
|
|
871
|
+
"the panel must read effortDropped strictly (=== true) so an older backend never renders a phantom downgrade"
|
|
872
|
+
);
|
|
873
|
+
// 2. 降级文案只在成功行追加,失败行不得出现(失败就是失败,别混进降级措辞)
|
|
874
|
+
assert.ok(
|
|
875
|
+
/\(testState\.ok && testState\.dropped \? " · " \+ t\("memory\.features\.modelTestDowngraded"\) : ""\)/.test(clientSource),
|
|
876
|
+
"the downgrade note must be appended only on the success line"
|
|
877
|
+
);
|
|
878
|
+
// 3. 双语词表各一条(按行首的 "key": "…" 计数,与上面的全键计数锁同口径;
|
|
879
|
+
// 用 split 会把渲染处的 t("…") 一起数进来,那是 3 次不是 2 次)
|
|
880
|
+
const tableRows = clientSource.match(/^\s*"memory\.features\.modelTestDowngraded"\s*:\s*"/gm) || [];
|
|
881
|
+
assert.equal(tableRows.length, 2, `memory.features.modelTestDowngraded must be declared exactly twice in the tables (zh + en), got ${tableRows.length}`);
|
|
882
|
+
// 4. 那条陈旧前提不得复活:面板不得再声称 sleep/梦 侧无回退重试
|
|
883
|
+
assert.ok(
|
|
884
|
+
!/无回退重试|无回退|no fallback retry/.test(clientSource),
|
|
885
|
+
"the panel must not re-assert the stale 'no fallback retry' claim that withEffortFallback already covers"
|
|
886
|
+
);
|
|
887
|
+
});
|
|
888
|
+
|
|
863
889
|
// 帮助与反馈入口(v0.8):设置页底部三个反馈链接——GitHub 新建 issue 预填
|
|
864
890
|
// (环境信息)、邮件反馈、浏览已知问题。纯前端链接零后端成本;插件版本从
|
|
865
891
|
// /info 拉取(version 只读,不铺任何 token/凭据)。公开链接不得带个人邮箱。
|
|
@@ -986,6 +1012,41 @@ test("scope isolation ships panel switches and provenance surfaces (A4)", () =>
|
|
|
986
1012
|
assert.ok(clientSource.includes('t("memory.explorer.detail.occurred")'), "drawer must render occurred row");
|
|
987
1013
|
});
|
|
988
1014
|
|
|
1015
|
+
// Issue #427:设置面板把折叠线从「冷门」挪到「新手之外」。锁四件事:
|
|
1016
|
+
// ①首屏层 = 常用组(3 总开关)+ 作用域组——scope 留在首屏是因为「它记的东西
|
|
1017
|
+
// 会不会被别的会话看到」是新用户的必答题,藏进折叠等于没答;②其余组整组收进
|
|
1018
|
+
// 「全部设置」(第一层折叠),「高级」降为它内部的第二层;③每条开关挂
|
|
1019
|
+
// 「默认 / 已改」徽标,信号取自 overrides(键不在其中 = 仍是 config schema
|
|
1020
|
+
// 默认值),面板自判、不加后端字段;④分组与折叠键中英成对。验收口径:首屏
|
|
1021
|
+
// 可点项 = 3 总开关 + autoInject 的 2 个子开关 + scope 2 开关 + 「全部设置」
|
|
1022
|
+
// 按钮 = 8(≤ 8),由 ①② 的组成正则共同钉死,不另数 DOM。
|
|
1023
|
+
test("settings card folds everything beyond the first screen behind All settings (issue #427)", () => {
|
|
1024
|
+
// ① 首屏层组成:两组都要在,且 basic 的成员是从 core/dream 移走的。
|
|
1025
|
+
assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.scope"\];/.test(clientSource),
|
|
1026
|
+
"the first screen must be exactly the basic and scope groups");
|
|
1027
|
+
assert.ok(/key: "group\.basic", items: \["autoInject", "autoSummarize", "autoDream"\]/.test(clientSource),
|
|
1028
|
+
"group.basic must carry exactly the three master switches");
|
|
1029
|
+
assert.ok(!/key: "group\.core", items: \["autoInject/.test(clientSource) && !/key: "group\.dream", items: \["autoDream/.test(clientSource),
|
|
1030
|
+
"basic members must be MOVED out of their old groups (render-once), not filtered at render time");
|
|
1031
|
+
// ② 两层折叠的接线:restGroups 只在 showAll 展开后渲染;「高级」按钮出现在
|
|
1032
|
+
// 「全部设置」之后(同一渲染分支内),showAdv 展开的条目走第二层。
|
|
1033
|
+
assert.ok(/showAll && h\(react\.Fragment, null,\s*restGroups\.map\(renderGroup\)/.test(clientSource),
|
|
1034
|
+
"the remaining groups must render only behind the All settings fold");
|
|
1035
|
+
const allBtn = clientSource.indexOf('t("memory.features.allToggle")');
|
|
1036
|
+
const advBtn = clientSource.indexOf('t("memory.features.advancedToggle")');
|
|
1037
|
+
assert.ok(allBtn >= 0 && advBtn > allBtn, "the advanced fold must be nested after All settings");
|
|
1038
|
+
assert.equal(clientSource.match(/const FEATURE_ADVANCED_BOOLS = \[([^\]]*)\];/)[1].split(",").filter((s) => s.trim()).length, 7,
|
|
1039
|
+
"the advanced second tier must hold exactly the 7 rarer switches the label promises");
|
|
1040
|
+
// ③ 徽标:两类各一套中英文案;判定读 overrides 本地判,不引入后端字段。
|
|
1041
|
+
for (const key of ["memory.features.tagDefault", "memory.features.tagChanged",
|
|
1042
|
+
"memory.features.group.basic", "memory.features.allToggle", "memory.features.advancedToggle"]) {
|
|
1043
|
+
const occurrences = clientSource.split("\"" + key + "\"").length - 1;
|
|
1044
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1045
|
+
}
|
|
1046
|
+
assert.ok(clientSource.includes("Object.prototype.hasOwnProperty.call(ovr, k)"),
|
|
1047
|
+
"the default/changed badge must be decided from overrides, not a new backend field");
|
|
1048
|
+
});
|
|
1049
|
+
|
|
989
1050
|
// v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
|
|
990
1051
|
// 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
|
|
991
1052
|
test("conflict queue ships a central review surface on the status tab", () => {
|
|
@@ -1580,3 +1641,301 @@ test("client bundle parses: 面板产物必须是合法 JS(无重复声明等
|
|
|
1580
1641
|
const result = spawnSync(process.execPath, ["--check", join(root, "lib/client.js")], { encoding: "utf8" });
|
|
1581
1642
|
assert.equal(result.status, 0, `lib/client.js 解析失败:\n${result.stderr || result.stdout}`);
|
|
1582
1643
|
});
|
|
1644
|
+
|
|
1645
|
+
// #399 的两个留存键(distillRecords.enabled / retentionDays)此前只有后端
|
|
1646
|
+
// 白名单与 docs/CONFIGURATION.md 的「面板可启停」,面板里根本没有这一行——
|
|
1647
|
+
// 文档承诺了一个够不着的开关。补上 UI 之后,这条锁住四件容易被「简化」掉的事:
|
|
1648
|
+
// ① 两个键真的进了渲染清单(enabled 在组里、retentionDays 在数值清单里);
|
|
1649
|
+
// ② 数值项的草稿必须从 effective 初始化——漏掉这一句,行会永远显示空值,
|
|
1650
|
+
// 用户以为配置丢了(#191 的睡眠路由键踩过同一个坑,见 :738 那条注释);
|
|
1651
|
+
// ③ 天数行必须由父开关门控(关着不留存,改天数没有意义);
|
|
1652
|
+
// ④ 非整数在本地就被挡下并把草稿退回生效值——留着非法文本会让人以为改成功了。
|
|
1653
|
+
test("distill retention switches are reachable from the panel (#399)", () => {
|
|
1654
|
+
// 认「组里有没有」不要用 `"key"]` 这种子串——`eff["distillRecords.enabled"]`
|
|
1655
|
+
// 也长这样,把组里那一项删掉它照样命中(变异验证实测:删项后仍全绿)。
|
|
1656
|
+
// 必须把 core 组的 items 数组抠出来单独看。
|
|
1657
|
+
const coreGroup = clientSource.match(/key: "group\.core", items: \[([\s\S]*?)\]/);
|
|
1658
|
+
assert.ok(coreGroup, "the core group must be declared");
|
|
1659
|
+
assert.ok(
|
|
1660
|
+
coreGroup[1].includes('"distillRecords.enabled"'),
|
|
1661
|
+
"distillRecords.enabled must be an item of the core group, not merely referenced elsewhere"
|
|
1662
|
+
);
|
|
1663
|
+
assert.ok(
|
|
1664
|
+
/const FEATURE_NUMBERS = \["distillRecords\.retentionDays"\]/.test(clientSource),
|
|
1665
|
+
"retentionDays must be in the numeric key list"
|
|
1666
|
+
);
|
|
1667
|
+
assert.ok(
|
|
1668
|
+
/for \(const key of FEATURE_NUMBERS\) numDraft\[key\] = String\(e\[key\] \?\? ""\);/.test(clientSource)
|
|
1669
|
+
&& /setNums\(numDraft\);/.test(clientSource),
|
|
1670
|
+
"the numeric draft must be seeded from effective on mount, or the row always renders empty"
|
|
1671
|
+
);
|
|
1672
|
+
assert.ok(
|
|
1673
|
+
/const distillSub = eff\["distillRecords\.enabled"\] && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*numRow\("distillRecords\.retentionDays"\)/.test(clientSource),
|
|
1674
|
+
"the retention-days row must be gated on the parent switch and live in its sub-block"
|
|
1675
|
+
);
|
|
1676
|
+
// 定义了不等于接线了:把 distillSub 从组尾子块里摘掉,上面每条断言照样全绿
|
|
1677
|
+
// (变异验证实测:摘掉后仍全绿)。子块是在 items.map 之后按组尾追加的,所以必须确认
|
|
1678
|
+
// 它真的出现在那一个渲染点上。
|
|
1679
|
+
const coreTail = clientSource.match(/g\.key === "group\.core" && h\(react\.Fragment, null, ([^)]*)\)/);
|
|
1680
|
+
assert.ok(coreTail, "the core group must append its sub-blocks at the group tail");
|
|
1681
|
+
assert.ok(
|
|
1682
|
+
/\bdistillSub\b/.test(coreTail[1]),
|
|
1683
|
+
"distillSub must be rendered at the core group tail, not merely declared above"
|
|
1684
|
+
);
|
|
1685
|
+
assert.ok(
|
|
1686
|
+
/if \(!\/\^\\d\+\$\/\.test\(raw\)\) \{\s*\n\s*setNums\(\(c\) => \(\{ \.\.\.c, \[key\]: String\(eff\[key\] \?\? ""\) \}\)\);\s*\n\s*return;\s*\n\s*\}/.test(clientSource),
|
|
1687
|
+
"a non-integer draft must be rejected locally and reverted to the effective value"
|
|
1688
|
+
);
|
|
1689
|
+
for (const key of [
|
|
1690
|
+
"memory.features.distillRecords.enabled",
|
|
1691
|
+
"memory.features.distillRecords.enabled.hint",
|
|
1692
|
+
"memory.features.distillRecords.retentionDays",
|
|
1693
|
+
"memory.features.distillRecords.retentionDays.hint"
|
|
1694
|
+
]) {
|
|
1695
|
+
const occurrences = clientSource.split(`"${key}"`).length - 1;
|
|
1696
|
+
assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
|
|
1697
|
+
}
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
test("a rejected feature write is visible and reverts the draft", () => {
|
|
1701
|
+
// 锁的是「保存失败从不显示」:错误提示原先只有一个座位、且带 `!state` 条件
|
|
1702
|
+
// (首屏加载失败才渲染)——state 一到手,此后每次写入失败都静默,输入框里
|
|
1703
|
+
// 留着没保存的值。整数项把「越界」变成可达路径后,人能在面板上看到 99999
|
|
1704
|
+
// 却以为它生效了。
|
|
1705
|
+
// 认「座位存在」不能只看 setSaveError 出现几次(函数里调它、渲染处读它,
|
|
1706
|
+
// 两处都在就算命中),要把写入路径与渲染路径分别钉住。
|
|
1707
|
+
assert.ok(
|
|
1708
|
+
clientSource.includes('const [saveError, setSaveError] = useState("");'),
|
|
1709
|
+
"save failures need their own state slot"
|
|
1710
|
+
);
|
|
1711
|
+
// 渲染座位必须在主卡片里(描述行之后、运行时块之前),而不是被 !state 挡住。
|
|
1712
|
+
const descAt = clientSource.indexOf('h("div", { className: "mneme-set-desc" }, t("memory.features.desc"))');
|
|
1713
|
+
const seatAt = clientSource.indexOf('saveError && h("div", { className: "mneme-mainterr" }, saveError)');
|
|
1714
|
+
const provAt = clientSource.indexOf("h(RuntimeProvisionBlock, { t })");
|
|
1715
|
+
assert.ok(descAt > 0 && seatAt > descAt, "the save-error seat must live in the main card body");
|
|
1716
|
+
assert.ok(provAt > seatAt, "the seat must sit in the card header area, before the runtime block");
|
|
1717
|
+
const earlyReturn = clientSource.match(/if \(error && !state\) return[\s\S]{0,300}?error\)\);/);
|
|
1718
|
+
assert.ok(earlyReturn, "the load-failure early return must still be there");
|
|
1719
|
+
assert.ok(!earlyReturn[0].includes("saveError"), "the save-error seat must not ride the load-failure branch");
|
|
1720
|
+
|
|
1721
|
+
// 写入路径:服务端 400 的 body 带着键名与范围(api.js 那条 400 的设计),
|
|
1722
|
+
// 要读出来;失败要能被调用方看见,所以返回值必须是真假。
|
|
1723
|
+
const putBody = clientSource.match(/const put = async \(patch\) => \{([\s\S]*?)\r?\n \};/);
|
|
1724
|
+
assert.ok(putBody, "put must be declared");
|
|
1725
|
+
assert.ok(putBody[1].includes("const detail = await res.json()"), "the server error body must be read");
|
|
1726
|
+
assert.ok(putBody[1].includes('throw new Error(detail || ("HTTP " + res.status));'), "the body must win over the bare status");
|
|
1727
|
+
assert.ok(putBody[1].includes("return true;") && putBody[1].includes("return false;"), "put must report success");
|
|
1728
|
+
assert.ok(putBody[1].includes("setSaveError(`"), "a rejected write must set the visible error");
|
|
1729
|
+
|
|
1730
|
+
// 两个提交路径都要在同一处退回生效值:只报错不退,框里的值仍与在用的值分家。
|
|
1731
|
+
assert.ok(clientSource.includes("put({ [key]: raw }).then((ok) => {"), "a rejected string draft must be revertible");
|
|
1732
|
+
assert.ok(clientSource.includes("put({ [key]: Number(raw) }).then((ok) => {"), "a rejected number draft must be revertible");
|
|
1733
|
+
assert.ok((clientSource.split("if (!ok) setStrs(").length - 1) >= 1, "the string draft must revert on rejection");
|
|
1734
|
+
assert.ok((clientSource.split("if (!ok) setNums(").length - 1) >= 1, "the number draft must revert on rejection");
|
|
1735
|
+
|
|
1736
|
+
const occurrences = clientSource.split('"memory.features.saveFailed"').length - 1;
|
|
1737
|
+
assert.ok(occurrences >= 2, `i18n key memory.features.saveFailed must exist in both zh and en (got ${occurrences})`);
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
// ── M.O.(Modus Operandi)面板的数据面 ────────────────────────────────────────
|
|
1741
|
+
// 第一版面板拿硬编码的 PIPELINE_MOCK 验证形状与交互(服务端管线当时还没落地)。
|
|
1742
|
+
// 接上真数据之后那份常量必须消失:留着它,面板随时可能退回展示一份并不存在的
|
|
1743
|
+
// 工作流——而这正是这一刀要修掉的东西。这里锁「没有」比锁「有什么」更要紧。
|
|
1744
|
+
test("M.O. panel carries no mock pipeline data", () => {
|
|
1745
|
+
assert.equal(
|
|
1746
|
+
clientSource.includes("PIPELINE_MOCK"),
|
|
1747
|
+
false,
|
|
1748
|
+
"the hardcoded PIPELINE_MOCK must be gone now that the panel reads the server"
|
|
1749
|
+
);
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
// 四个座位——会话头图标、会话头浮层、右栏 tab、对话行结算卡——必须读同一份投影。
|
|
1753
|
+
// 分家的事故长这样:会话头说有 3 条候选、右栏说有 5 条,而两个数字都对、没人查得出来。
|
|
1754
|
+
// 所以缓存只许有一处(usePipeline),其余要么从它取,要么经 PipelineBrowser 取。
|
|
1755
|
+
test("M.O. seats share a single pipeline document", () => {
|
|
1756
|
+
assert.equal(
|
|
1757
|
+
(clientSource.match(/function usePipeline\(/g) || []).length,
|
|
1758
|
+
1,
|
|
1759
|
+
"usePipeline must be defined exactly once — one cache, every seat"
|
|
1760
|
+
);
|
|
1761
|
+
const bodyOf = (name) => {
|
|
1762
|
+
const start = clientSource.indexOf(`function ${name}(`);
|
|
1763
|
+
assert.ok(start > -1, `${name} must exist`);
|
|
1764
|
+
const next = clientSource.slice(start + 1).indexOf("\n function ");
|
|
1765
|
+
return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
|
|
1766
|
+
};
|
|
1767
|
+
for (const seat of ["PipelineHeaderEntry", "TurnRunCard"]) {
|
|
1768
|
+
assert.ok(
|
|
1769
|
+
bodyOf(seat).includes("usePipeline(project, null)"),
|
|
1770
|
+
`${seat} must read the shared pipeline document`
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
// 另两个座位不再自己调 hook:它们把 project 交给 PipelineBrowser,由它按 openId 决定
|
|
1774
|
+
// 读清单还是读某一条。hook 不能条件调用,所以这道分叉必须落在**组件之间**而不是
|
|
1775
|
+
// 一个组件里的 if/else——后者会在点开那一下炸成「Rendered more hooks than during
|
|
1776
|
+
// the previous render」,而那是运行期才看得见的错。
|
|
1777
|
+
assert.ok(
|
|
1778
|
+
bodyOf("PipelinePane").includes("h(PipelineBrowser, { t, project })"),
|
|
1779
|
+
"PipelinePane must delegate to PipelineBrowser"
|
|
1780
|
+
);
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
// 清单与图**同屏**。这条锁的形状换过两次,两次的教训都写在这里,免得再翻回去:
|
|
1784
|
+
// 一进来就是图 —— 没有名字、没有落点,要的是一串有名字的条目;
|
|
1785
|
+
// 清单 ⟷ 图两屏来回切 —— 条目有了,但画布藏在一次点击后面、界面上没有任何提示,
|
|
1786
|
+
// 反馈连着两次说「看不到画布和节点」。
|
|
1787
|
+
// 所以锁的是:清单行渲染名字/作用域/规模,**且** 图与清单在同一个组件里同时出现。
|
|
1788
|
+
test("M.O. browser shows the named list and the canvas on one screen", () => {
|
|
1789
|
+
const bodyOf = (name) => {
|
|
1790
|
+
const start = clientSource.indexOf(`function ${name}(`);
|
|
1791
|
+
assert.ok(start > -1, `${name} must exist`);
|
|
1792
|
+
const next = clientSource.slice(start + 1).indexOf("\n function ");
|
|
1793
|
+
return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
|
|
1794
|
+
};
|
|
1795
|
+
const list = bodyOf("PipelineWorkflowList");
|
|
1796
|
+
assert.ok(list.includes("row.title || row.id"), "each row must show a name");
|
|
1797
|
+
assert.ok(list.includes("memory.actions.scope.project"), "each row must show its scope");
|
|
1798
|
+
assert.ok(list.includes("memory.actions.list.meta"), "each row must show its size");
|
|
1799
|
+
const browser = bodyOf("PipelineBrowser");
|
|
1800
|
+
assert.ok(browser.includes("PipelineWorkflowList"), "the named list must be on screen");
|
|
1801
|
+
assert.ok(
|
|
1802
|
+
browser.includes("h(PipelineView, { t, data: detailDoc })"),
|
|
1803
|
+
"the canvas must be on the SAME screen — not behind a click"
|
|
1804
|
+
);
|
|
1805
|
+
assert.ok(browser.includes("rows.length ? rows[0].id : null"), "the first workflow must be expanded by default");
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
// 面板读的是服务端投影,不是本地算的:工作流本体住仓库、投影住服务端,
|
|
1809
|
+
// 面板只负责画。路由一旦改了名字、或者项目根忘了带,这条会先红。
|
|
1810
|
+
test("M.O. panel reads the server projection", () => {
|
|
1811
|
+
assert.match(
|
|
1812
|
+
clientSource,
|
|
1813
|
+
/"\/api\/dsh-mneme\/pipeline" \+/,
|
|
1814
|
+
"the panel must fetch the pipeline route"
|
|
1815
|
+
);
|
|
1816
|
+
// 项目根是**客户端从会话读出来带过去的**,不是服务端猜的:插件侧的 ctx 上没有 cwd
|
|
1817
|
+
// (只有 logger / llm / reflect / webServer / inject / commands),猜不出来。
|
|
1818
|
+
assert.ok(
|
|
1819
|
+
clientSource.includes('params.push("project=" + encodeURIComponent(project))'),
|
|
1820
|
+
"the panel must pass the session's project root to the route"
|
|
1821
|
+
);
|
|
1822
|
+
assert.ok(
|
|
1823
|
+
clientSource.includes("function sessionProject(ctx, sessionId)"),
|
|
1824
|
+
"the session cwd lookup must exist and stay named"
|
|
1825
|
+
);
|
|
1826
|
+
// 两条来源的先后:会话列表快照(byId[sessionId].cwd)是宿主给客户端的公开形状,
|
|
1827
|
+
// sessions.get() 是宿主侧写法、客户端不一定有。顺序反了不会报错,只会让面板静悄悄
|
|
1828
|
+
// 退回全局层——那跟「本项目还没有工作流」长得一模一样,是最难查的一类错。
|
|
1829
|
+
// 而且两条必须各兜各的异常:一条抛了不能把另一条也带走,否则同样表现成「项目未知」。
|
|
1830
|
+
const sessionProjectBody = clientSource.slice(
|
|
1831
|
+
clientSource.indexOf("function sessionProject("),
|
|
1832
|
+
clientSource.indexOf("function layoutDag(")
|
|
1833
|
+
);
|
|
1834
|
+
assert.ok(
|
|
1835
|
+
sessionProjectBody.indexOf("sessions.list.getSnapshot") <
|
|
1836
|
+
sessionProjectBody.indexOf("typeof sessions.get"),
|
|
1837
|
+
"the host's public shape (list snapshot) must be tried before the host-side accessor"
|
|
1838
|
+
);
|
|
1839
|
+
const betweenLookups = sessionProjectBody.slice(
|
|
1840
|
+
sessionProjectBody.indexOf("sessions.list.getSnapshot"),
|
|
1841
|
+
sessionProjectBody.indexOf("typeof sessions.get")
|
|
1842
|
+
);
|
|
1843
|
+
assert.ok(
|
|
1844
|
+
betweenLookups.includes("catch (err)"),
|
|
1845
|
+
"each lookup must carry its own catch — a throwing accessor must not take out the next one"
|
|
1846
|
+
);
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
// 右栏 tab 的正文单独包了一层具名组件,而不是把 hook 直接塞进 slots.register 的回调里:
|
|
1850
|
+
// 那个回调是不是当组件渲染由宿主决定,赌错就是「Rendered more hooks than during the
|
|
1851
|
+
// previous render」这种只在运行时炸、测试看不见的错。
|
|
1852
|
+
test("right-pane tab renders a named component instead of a bare hook callback", () => {
|
|
1853
|
+
assert.match(clientSource, /function PipelinePane\(/, "PipelinePane must exist");
|
|
1854
|
+
assert.ok(
|
|
1855
|
+
clientSource.includes(
|
|
1856
|
+
"}, (props) => h(PipelinePane, { t, project: sessionProject(ctx, props && props.sessionId) }));"
|
|
1857
|
+
),
|
|
1858
|
+
"the tab callback must delegate to the named PipelinePane component, with the session's project"
|
|
1859
|
+
);
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
// 「仓库里还没有工作流」与「一条运行记录都没有」是两句不同的话,都得中英齐全——
|
|
1863
|
+
// i18n 掉一边,界面上就是一行 key 裸奔。作用域那三句尤其不能缺:清单行上「项目」
|
|
1864
|
+
// 与「全局」是唯一的来源提示,缺了它,人分不清手里这条是本项目的还是跟着插件来的。
|
|
1865
|
+
test("M.O. empty-state, list and scope copy exist in both languages", () => {
|
|
1866
|
+
for (const key of [
|
|
1867
|
+
"memory.actions.empty.title",
|
|
1868
|
+
"memory.actions.empty.body",
|
|
1869
|
+
"memory.actions.noRuns",
|
|
1870
|
+
"memory.actions.zoom.in",
|
|
1871
|
+
"memory.actions.zoom.out",
|
|
1872
|
+
"memory.actions.zoom.fit",
|
|
1873
|
+
"memory.actions.list.heading",
|
|
1874
|
+
"memory.actions.list.count",
|
|
1875
|
+
"memory.actions.list.meta",
|
|
1876
|
+
"memory.actions.scope.project",
|
|
1877
|
+
"memory.actions.scope.global",
|
|
1878
|
+
"memory.actions.scope.none",
|
|
1879
|
+
"memory.actions.broken"
|
|
1880
|
+
]) {
|
|
1881
|
+
const hits = (clientSource.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) || []).length;
|
|
1882
|
+
assert.equal(hits, 2, `${key} must exist in both zh and en (got ${hits})`);
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
// 画布能放大缩小。锁的重点不是「有没有缩放」,而是滚轮那一条必须**自己挂非被动监听**:
|
|
1887
|
+
// React 的 onWheel 挂在 root 上且是 passive 的,preventDefault 会被浏览器忽略,
|
|
1888
|
+
// 于是 Ctrl+滚轮既缩放画布又缩放整页,谁也没拦住——这个错本地测不出来,只在真浏览器里响。
|
|
1889
|
+
test("M.O. canvas zooms: buttons plus a non-passive, modifier-gated wheel listener", () => {
|
|
1890
|
+
const bodyOf = (name) => {
|
|
1891
|
+
const start = clientSource.indexOf(`function ${name}(`);
|
|
1892
|
+
assert.ok(start > -1, `${name} must exist`);
|
|
1893
|
+
const next = clientSource.slice(start + 1).indexOf("\n function ");
|
|
1894
|
+
return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
|
|
1895
|
+
};
|
|
1896
|
+
const view = bodyOf("PipelineView");
|
|
1897
|
+
assert.ok(view.includes("PipelineZoomBar"), "the zoom control must be rendered");
|
|
1898
|
+
assert.ok(view.includes("{ passive: false }"), "the wheel listener must be non-passive, or preventDefault is ignored");
|
|
1899
|
+
assert.ok(view.includes("ev.ctrlKey") && view.includes("ev.metaKey"), "plain wheel must stay a scroll, not a zoom");
|
|
1900
|
+
assert.ok(view.includes('width: (zoom * 100) + "%"'), "zoom must change the layout width so the container can scroll to it");
|
|
1901
|
+
const bar = bodyOf("PipelineZoomBar");
|
|
1902
|
+
assert.ok(bar.includes("memory.actions.zoom.in"), "zoom in must be reachable by button, not only by wheel");
|
|
1903
|
+
assert.ok(bar.includes("memory.actions.zoom.out"), "zoom out must be reachable by button");
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
// 「tab 的类型」与「tab 的正文」必须是两件互不相干的事,而且类型注册要幂等。
|
|
1907
|
+
// 为什么会栽(2026-10-16 真机上现形):宿主对重复的类型 id 会抛
|
|
1908
|
+
// (sidebarRight: tab type id "…" is already registered),而类型注册挂在宿主服务上、
|
|
1909
|
+
// 插件重挂(刷新 / 热重载 / 会话重建)时**留着**,正文座位却是挂插件 scope、随插件回收的。
|
|
1910
|
+
// 两件事原来同一个 try —— 第二次挂载时那一抛把正文一起带走,界面上只剩宿主的 fallback
|
|
1911
|
+
// 「这类内容还没有可用的查看方式」,错误只在浏览器控制台里,界面上零痕迹。
|
|
1912
|
+
test("M.O. tab type registration is idempotent and isolated from the pane body", () => {
|
|
1913
|
+
const typeAt = clientSource.indexOf("id: PIPELINE_ACTIONS_TAB_ID,");
|
|
1914
|
+
const bodyAt = clientSource.indexOf('ctx.slots.inject("sidebar.right.pane.tab"');
|
|
1915
|
+
assert.ok(typeAt > -1, "the tab type registration must exist");
|
|
1916
|
+
assert.ok(bodyAt > typeAt, "the pane body registration must exist, after the type");
|
|
1917
|
+
assert.ok(
|
|
1918
|
+
clientSource.includes("tabs.get(PIPELINE_ACTIONS_TAB_ID)"),
|
|
1919
|
+
"the type registration must be idempotent — a re-mount must not re-register the id"
|
|
1920
|
+
);
|
|
1921
|
+
assert.ok(
|
|
1922
|
+
clientSource.slice(typeAt, bodyAt).includes("catch (err)"),
|
|
1923
|
+
"the type registration must carry its own catch — its failure must not take out the pane body"
|
|
1924
|
+
);
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
// 回归锁:M.O. 段里每个 .mneme-pipe-* 类名在样式表里只许有**一条**规则。
|
|
1928
|
+
// 为什么会栽在这里(2026-10-16,真机上才现形):新写的详情屏容器与既有的「节点详情」面板
|
|
1929
|
+
// 撞了同名 .mneme-pipe-detail —— 后写的 flex:1 赢下冲突,先写的 max-height:180px; overflow:auto
|
|
1930
|
+
// 却留着,于是详情屏被钉在 180px、里面的画布 flex:1 分到 0 像素,屏幕上只剩空白加一条滚动条。
|
|
1931
|
+
// 只查颜色的 contrast.test.js 抓不到这类撞车;它只有在屏幕上看才现形,所以要在这里钉住。
|
|
1932
|
+
test("M.O. class names do not collide in the stylesheet", () => {
|
|
1933
|
+
const declared = new Map();
|
|
1934
|
+
for (const m of clientSource.matchAll(/"\.mneme-pipe-([a-z0-9-]+)\{/g)) {
|
|
1935
|
+
declared.set(m[1], (declared.get(m[1]) || 0) + 1);
|
|
1936
|
+
}
|
|
1937
|
+
const dupes = [...declared].filter(([, n]) => n > 1).map(([k, n]) => `.mneme-pipe-${k}×${n}`);
|
|
1938
|
+
assert.deepEqual(dupes, [], `duplicate .mneme-pipe-* rule(s): ${dupes.join(", ")}`);
|
|
1939
|
+
// 扫描量也得钉:正则一旦匹配不到东西,上面那条会静默变绿(例如样式表被整块挪走)。
|
|
1940
|
+
assert.ok(declared.size >= 20, `the scan must actually see the pipeline stylesheet (saw ${declared.size})`);
|
|
1941
|
+
});
|
package/test/continuity.test.js
CHANGED
|
@@ -331,3 +331,87 @@ test("#249 N3:存量库启动即建表(幂等迁移),重开后提案还
|
|
|
331
331
|
rmSync(dir, { recursive: true, force: true });
|
|
332
332
|
}
|
|
333
333
|
});
|
|
334
|
+
|
|
335
|
+
test("#249 N3:边缘只认「真的落地」——start 与带错的 end 都不算", async () => {
|
|
336
|
+
const store = createStore(":memory:");
|
|
337
|
+
const ctx = mockCtx();
|
|
338
|
+
const rescue = createContinuityRescue(ctx, store);
|
|
339
|
+
const session = fakeSession("s1", [userEvent(1, "指令")]);
|
|
340
|
+
const step = () => ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] }));
|
|
341
|
+
|
|
342
|
+
// 宿主的压缩插件在**每一步**都试一次压缩(pre-step 里的 compactIfNeeded),所以
|
|
343
|
+
// `compaction/start` 只在说「有人试过」。按「试过就救」记账,触发器就变成「每步一次」。
|
|
344
|
+
ctx.fire("session/event", session, { type: "compaction/start", seq: 3, data: { compactionId: "c1", turn: 1 } });
|
|
345
|
+
assert.deepEqual((await step()).messages, [], "试过压缩不算边缘——没有东西被折叠,就没有东西要抢救");
|
|
346
|
+
|
|
347
|
+
// 失败路也会发 end(同一份源码:失败路 append 带 error),所以带 error 的 end 同样不算。
|
|
348
|
+
// 本机实测:1,287 次 start / 1,287 次 end 全部带 error —— 按旧口径,这一整段每一步都在救。
|
|
349
|
+
ctx.fire("session/event", session, { type: "compaction/end", seq: 4, data: { compactionId: "c1", turn: 1, error: "422: dsh_compaction_refused" } });
|
|
350
|
+
assert.deepEqual((await step()).messages, [], "压缩失败 → 历史没被折叠 → 不抢救");
|
|
351
|
+
assert.equal(store.countContinuityProposals(), 0, "两半都不动作:提案也不该落");
|
|
352
|
+
rescue.dispose();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("#249 N3:三种「真落地」都算边缘——summary / 无错 end / prune", async () => {
|
|
356
|
+
const LANDED = [
|
|
357
|
+
["compaction/summary(宿主压缩成功才落)", { type: "compaction/summary", data: { compactionId: "c", turn: 1 } }],
|
|
358
|
+
["compaction/end 不带 error(成功路)", { type: "compaction/end", data: { compactionId: "c", turn: 1 } }],
|
|
359
|
+
["compaction/prune(表面真的变短)", { type: "compaction/prune", data: { shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], shadowedTokenCount: 100 } }]
|
|
360
|
+
];
|
|
361
|
+
for (const [label, event] of LANDED) {
|
|
362
|
+
const ctx = mockCtx();
|
|
363
|
+
const rescue = createContinuityRescue(ctx, createStore(":memory:"));
|
|
364
|
+
const session = fakeSession("s1", [userEvent(1, "指令")]);
|
|
365
|
+
ctx.fire("session/event", session, { ...event, seq: 9 });
|
|
366
|
+
const decision = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] }));
|
|
367
|
+
assert.equal(decision.messages.length, 1, `${label} 是「历史真被折叠」的信号,要抢救`);
|
|
368
|
+
rescue.dispose();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("#249 N3:失败尝试不产生注入洪流,真折叠才抢救(实测形状)", async () => {
|
|
373
|
+
const store = createStore(":memory:");
|
|
374
|
+
const ctx = mockCtx();
|
|
375
|
+
const rescue = createContinuityRescue(ctx, store);
|
|
376
|
+
const events = [userEvent(1, "把 N3 实现完")];
|
|
377
|
+
const nodes = [1];
|
|
378
|
+
const session = fakeSession("s1", events, nodes);
|
|
379
|
+
let seq = 100;
|
|
380
|
+
let injected = 0;
|
|
381
|
+
// 宿主把追加的消息落进会话(表面 + 日志)——判重按表面,这一步必须照实模拟,否则
|
|
382
|
+
// 「不变不重复」永远不生效,测出来的是判重缺席的样子。
|
|
383
|
+
const step = async () => {
|
|
384
|
+
const decision = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] }));
|
|
385
|
+
for (const message of decision.messages) {
|
|
386
|
+
seq += 1;
|
|
387
|
+
events.push({ type: "user/message", seq, data: { content: message.content, source: message.source } });
|
|
388
|
+
nodes.push(seq);
|
|
389
|
+
injected += 1;
|
|
390
|
+
}
|
|
391
|
+
return decision;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// 实测里最常见的形状(本机 1,591 步 / 1,230 次尝试):一步步试压缩、次次失败。
|
|
395
|
+
// 每步都推进一条 assistant/message,于是快照文本每步都不同——「不变不重复」挡不住它,
|
|
396
|
+
// 挡得住它的只有「这段历史到底有没有被折叠」这一个判断。改前口径在这里会注入 50 次。
|
|
397
|
+
for (let i = 0; i < 50; i += 1) {
|
|
398
|
+
seq += 1;
|
|
399
|
+
events.push(assistantEvent(seq, `第 ${i} 步的回复`));
|
|
400
|
+
seq += 1;
|
|
401
|
+
ctx.fire("session/event", session, { type: "compaction/start", seq, data: { compactionId: `c${i}`, turn: i } });
|
|
402
|
+
seq += 1;
|
|
403
|
+
ctx.fire("session/event", session, { type: "compaction/end", seq, data: { compactionId: `c${i}`, turn: i, error: "422: dsh_compaction_refused" } });
|
|
404
|
+
await step();
|
|
405
|
+
}
|
|
406
|
+
assert.equal(injected, 0, "50 次失败尝试 → 0 次注入(改前 50 次:每一步都救了一次没被折叠的历史)");
|
|
407
|
+
|
|
408
|
+
// 真的折叠了一次 → 恰好抢救一次。
|
|
409
|
+
seq += 1;
|
|
410
|
+
events.push(assistantEvent(seq, "接着写回归测试"));
|
|
411
|
+
seq += 1;
|
|
412
|
+
ctx.fire("session/event", session, { type: "compaction/prune", seq, data: { shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], shadowedTokenCount: 100 } });
|
|
413
|
+
await step();
|
|
414
|
+
assert.equal(injected, 1, "表面真的变短了 → 抢救一次");
|
|
415
|
+
assert.equal(store.getContinuityProposal("s1", KIND).current_work, "把 N3 实现完", "落库那一半照常");
|
|
416
|
+
rescue.dispose();
|
|
417
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
// 这条锁防的是一类「上限形同不存在」的回归:上限判断如果排在会抛错的那次读取
|
|
7
|
+
// 后面,轮询就永远不会停手。
|
|
8
|
+
//
|
|
9
|
+
// service.js 的 setEmbedder 为异步初始化的 embedder 起了个轮询:每 100ms 读一次
|
|
10
|
+
// `emb.ready`,读到 true 或撞上 EMBED_READY_POLL_LIMIT(=30) 就停
|
|
11
|
+
// (注释原文 "~3s ceiling; never poll forever")。但 `ready` 是个 getter
|
|
12
|
+
// —— embedding.js 的 OpenAI embedder 直接读库 —— 于是写成
|
|
13
|
+
// if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) { stop(); }
|
|
14
|
+
// 的整条 `||` 会在左半边当场中断,右半边的天花板永远轮不到:stopEmbedReadyPolling()
|
|
15
|
+
// 不执行,定时器活过上限,并且每 100ms 抛一次未捕获异常。
|
|
16
|
+
//
|
|
17
|
+
// 实测现场(2026-10-14,加蒸馏留存的装配级用例时撞上):fiber.dispose() 走完
|
|
18
|
+
// index.js 的 disposer(末尾一句就是 store.close())之后,每次读 ready 都抛
|
|
19
|
+
// "database is not open" [ERR_INVALID_STATE],5s 内读了 46 次 —— 天花板 31 被
|
|
20
|
+
// 击穿,node --test 进程因此不再退出。一个只在启动时读一次库的 getter,能把
|
|
21
|
+
// 宿主的事件循环钉死;而现有测试全都「装配完不拆」,所以从 v0.7.1 起一直没人碰到。
|
|
22
|
+
//
|
|
23
|
+
// 时序照真实卸载顺序复现:安装时未就绪(轮询启动)→ store.close()(此后每次读
|
|
24
|
+
// ready 都抛)→ 等超过天花板 → 看它停没停手、报了几次。
|
|
25
|
+
const POLL_LIMIT = 30; // 与 src/service.js 的 EMBED_READY_POLL_LIMIT 同值,改那边要改这里
|
|
26
|
+
const POLL_MS = 100;
|
|
27
|
+
const CEILING_READS = 1 + POLL_LIMIT; // setEmbedder 自己读一次,之后每个 tick 一次
|
|
28
|
+
const SETTLE_MS = 400; // 静默这么久没新读取,就认为定时器已经停手
|
|
29
|
+
const DEADLINE_MS = 30000; // 上限:等到这里还没静默就按超时处理
|
|
30
|
+
|
|
31
|
+
// 等「读取次数不再增长」。不能用「睡够 POLL_MS*30 + 600ms 再断言」——
|
|
32
|
+
// 全量 npm test 是多文件并行跑的,CPU 一挤,30 个 tick 未必在 3.6s 内跑完,
|
|
33
|
+
// 断言就会读到 30 而不是 31(2026-10-15 rebase 后在全量跑里实测撞到一次,
|
|
34
|
+
// 单跑 3/3 通过、紧接着全量跑两遍也全绿——纯粹的负载抖动)。
|
|
35
|
+
// 静默判定是可靠的:定时器周期 100ms,而越过上限后 stopEmbedReadyPolling()
|
|
36
|
+
// 会 clearInterval,此后 reads 恒不再变,两者不会混淆。
|
|
37
|
+
async function waitUntilSettled(getReads) {
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
let last = getReads();
|
|
40
|
+
let lastChange = Date.now();
|
|
41
|
+
while (Date.now() - started < DEADLINE_MS) {
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
43
|
+
const now = getReads();
|
|
44
|
+
if (now !== last) {
|
|
45
|
+
last = now;
|
|
46
|
+
lastChange = Date.now();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (Date.now() - lastChange >= SETTLE_MS) return now;
|
|
50
|
+
}
|
|
51
|
+
return getReads();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
test("the embedder ready poll stops at its ceiling even when reading ready throws", async () => {
|
|
55
|
+
const warns = [];
|
|
56
|
+
const store = createStore(":memory:");
|
|
57
|
+
const service = createService({
|
|
58
|
+
store,
|
|
59
|
+
mirror: null,
|
|
60
|
+
config: {},
|
|
61
|
+
logger: { warn: (...args) => warns.push(args) }
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let reads = 0;
|
|
65
|
+
let closed = false;
|
|
66
|
+
const embedder = {
|
|
67
|
+
get ready() {
|
|
68
|
+
reads++;
|
|
69
|
+
if (closed) {
|
|
70
|
+
// 与 embedding.js 的 OpenAI embedder 同形:getter 内部经 settings 读库。
|
|
71
|
+
store.db.prepare("SELECT count(*) AS c FROM memories").get();
|
|
72
|
+
}
|
|
73
|
+
return false; // 始终「未就绪」:轮询会一直跑下去,除非天花板真的生效
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
service.setEmbedder(embedder);
|
|
78
|
+
assert.equal(reads, 1, "setEmbedder 先读一次 ready,决定要不要起轮询");
|
|
79
|
+
|
|
80
|
+
closed = true;
|
|
81
|
+
store.close(); // 卸载路径的最后一步
|
|
82
|
+
|
|
83
|
+
await waitUntilSettled(() => reads);
|
|
84
|
+
const atCeiling = reads;
|
|
85
|
+
|
|
86
|
+
assert.equal(
|
|
87
|
+
atCeiling,
|
|
88
|
+
CEILING_READS,
|
|
89
|
+
`读 ready 抛错也必须按上限停手(实际读了 ${atCeiling} 次,上限 ${CEILING_READS})`
|
|
90
|
+
);
|
|
91
|
+
assert.equal(reads, atCeiling, "越过天花板之后不许再有读取:定时器必须已经清掉");
|
|
92
|
+
assert.equal(warns.length, 1, "读不出来只报一次,别把 3s 上限变成 30 条刷屏");
|
|
93
|
+
assert.match(String(warns[0][0]), /embedder ready probe failed/);
|
|
94
|
+
});
|
package/test/entities.test.js
CHANGED
|
@@ -533,7 +533,9 @@ test("extractor passes provider/model/reasoningEffort through to callLLM options
|
|
|
533
533
|
const config = { entityExtractionProvider: "openai", entityExtractionModel: "gpt-x", entityExtractionReasoning: "high" };
|
|
534
534
|
const result = await extractEntities({ id: "m1", content: "Vite 是前端构建工具" }, { store, config, callLLM });
|
|
535
535
|
assert.equal(result.ok, true);
|
|
536
|
-
|
|
536
|
+
// memoryId 是 #405 加的归属通道(审计行靠它记 related_memory_ids),与 #109 的
|
|
537
|
+
// provider/model/reasoningEffort 走同一条 options 缝;形状锁保持精确相等。
|
|
538
|
+
assert.deepEqual(captured, { provider: "openai", model: "gpt-x", reasoningEffort: "high", memoryId: "m1" });
|
|
537
539
|
store.close();
|
|
538
540
|
});
|
|
539
541
|
|
|
@@ -542,7 +544,16 @@ test("extractor omits empty provider/model and 'none' reasoning from options", a
|
|
|
542
544
|
let captured = "unset";
|
|
543
545
|
const callLLM = async (_m, options) => { captured = options; return JSON.stringify({ entities: [], relations: [] }); };
|
|
544
546
|
await extractEntities({ id: "m2", content: "空文本" }, { store, config: {}, callLLM });
|
|
545
|
-
assert.deepEqual(captured, {}, "no provider/model/reasoning keys when unset");
|
|
547
|
+
assert.deepEqual(captured, { memoryId: "m2" }, "no provider/model/reasoning keys when unset");
|
|
548
|
+
store.close();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("#405: extractor omits memoryId when the memory has no id (no fabricated attribution)", async () => {
|
|
552
|
+
const store = openStore();
|
|
553
|
+
let captured = "unset";
|
|
554
|
+
const callLLM = async (_m, options) => { captured = options; return JSON.stringify({ entities: [], relations: [] }); };
|
|
555
|
+
await extractEntities({ content: "无 id 的记忆" }, { store, config: {}, callLLM });
|
|
556
|
+
assert.deepEqual(captured, {}, "an id-less memory leaves the attribution channel empty");
|
|
546
557
|
store.close();
|
|
547
558
|
});
|
|
548
559
|
|