@modusensus/dsh-mneme 0.8.16 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +91 -16
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +8 -2
  4. package/docs/CONFIGURATION.md +27 -1
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/SEMANTIC.md +3 -3
  7. package/docs/WORKFLOWS.md +299 -0
  8. package/lib/api-standalone.js +78 -12
  9. package/lib/api.js +403 -62
  10. package/lib/candidates.js +454 -0
  11. package/lib/capabilities.js +819 -0
  12. package/lib/client.js +2294 -423
  13. package/lib/config.js +63 -6
  14. package/lib/continuity.js +35 -6
  15. package/lib/dream/decisions.js +17 -1
  16. package/lib/dream/sleep.js +10 -2
  17. package/lib/dream.js +5 -1
  18. package/lib/embedding.js +116 -15
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/index.js +81 -12
  21. package/lib/inject.js +40 -4
  22. package/lib/lang.js +124 -0
  23. package/lib/pipeline-activities.js +178 -0
  24. package/lib/pipeline-attribution.js +165 -0
  25. package/lib/pipeline-command.js +279 -0
  26. package/lib/pipeline-confirm.js +142 -0
  27. package/lib/pipeline-exec.js +174 -0
  28. package/lib/pipeline-nudge.js +1281 -0
  29. package/lib/pipeline-observations.js +181 -0
  30. package/lib/pipeline-observe.js +133 -0
  31. package/lib/pipeline-runner.js +346 -0
  32. package/lib/pipeline-runs.js +442 -0
  33. package/lib/pipeline.js +655 -0
  34. package/lib/scope.js +16 -4
  35. package/lib/semantic.js +66 -10
  36. package/lib/serve.js +1 -1
  37. package/lib/service.js +59 -18
  38. package/lib/session-usage.js +350 -0
  39. package/lib/settings.js +138 -1
  40. package/lib/store.js +132 -6
  41. package/lib/summarize.js +30 -0
  42. package/lib/tools.js +11 -2
  43. package/package.json +3 -3
  44. package/scripts/changelog-prep.mjs +62 -7
  45. package/scripts/check-sync.js +8 -6
  46. package/scripts/publish-gate.mjs +197 -0
  47. package/scripts/release-docs.mjs +204 -0
  48. package/scripts/release-notes.mjs +162 -0
  49. package/src/api-standalone.js +78 -12
  50. package/src/api.js +403 -62
  51. package/src/candidates.js +454 -0
  52. package/src/capabilities.js +819 -0
  53. package/src/config.js +63 -6
  54. package/src/continuity.js +35 -6
  55. package/src/dream/decisions.js +17 -1
  56. package/src/dream/sleep.js +10 -2
  57. package/src/dream.js +5 -1
  58. package/src/embedding.js +116 -15
  59. package/src/entities/extractor.js +4 -0
  60. package/src/index.js +81 -12
  61. package/src/inject.js +40 -4
  62. package/src/lang.js +124 -0
  63. package/src/pipeline-activities.js +178 -0
  64. package/src/pipeline-attribution.js +165 -0
  65. package/src/pipeline-command.js +279 -0
  66. package/src/pipeline-confirm.js +142 -0
  67. package/src/pipeline-exec.js +174 -0
  68. package/src/pipeline-nudge.js +1281 -0
  69. package/src/pipeline-observations.js +181 -0
  70. package/src/pipeline-observe.js +133 -0
  71. package/src/pipeline-runner.js +346 -0
  72. package/src/pipeline-runs.js +442 -0
  73. package/src/pipeline.js +655 -0
  74. package/src/scope.js +16 -4
  75. package/src/semantic.js +66 -10
  76. package/src/serve.js +1 -1
  77. package/src/service.js +59 -18
  78. package/src/session-usage.js +350 -0
  79. package/src/settings.js +138 -1
  80. package/src/store.js +132 -6
  81. package/src/summarize.js +30 -0
  82. package/src/tools.js +11 -2
  83. package/test/api-hardening.test.js +173 -0
  84. package/test/api.test.js +244 -7
  85. package/test/candidates.test.js +345 -0
  86. package/test/capabilities.test.js +577 -0
  87. package/test/changelog-prep.test.js +74 -8
  88. package/test/cli-enums.test.js +37 -0
  89. package/test/client.test.js +510 -2
  90. package/test/continuity.test.js +84 -0
  91. package/test/docs-config-keys.test.js +65 -0
  92. package/test/embed-ready-poll.test.js +94 -0
  93. package/test/entities.test.js +13 -2
  94. package/test/failure-query.test.js +147 -0
  95. package/test/failure-retention.test.js +105 -0
  96. package/test/graph-api.test.js +36 -2
  97. package/test/helpers/write-admission-samples.js +6 -2
  98. package/test/inject-recall.test.js +32 -4
  99. package/test/lib-smoke.test.js +20 -0
  100. package/test/llm-audit.test.js +92 -1
  101. package/test/mint-source.test.js +202 -0
  102. package/test/persona-api.test.js +172 -0
  103. package/test/persona-flag.test.js +36 -0
  104. package/test/persona-inject.test.js +145 -0
  105. package/test/persona-settings.test.js +145 -0
  106. package/test/pipeline-activities.test.js +155 -0
  107. package/test/pipeline-attribution.test.js +175 -0
  108. package/test/pipeline-command.test.js +243 -0
  109. package/test/pipeline-confirm.test.js +148 -0
  110. package/test/pipeline-exec.test.js +172 -0
  111. package/test/pipeline-nudge.test.js +1036 -0
  112. package/test/pipeline-observations.test.js +157 -0
  113. package/test/pipeline-observe.test.js +127 -0
  114. package/test/pipeline-r1r2.test.js +149 -0
  115. package/test/pipeline-runner.test.js +442 -0
  116. package/test/pipeline-runs.test.js +767 -0
  117. package/test/pipeline.test.js +636 -0
  118. package/test/placement.test.js +1 -1
  119. package/test/publish-gate.test.js +272 -0
  120. package/test/recall-retention.test.js +128 -0
  121. package/test/reindex-backfill.test.js +83 -4
  122. package/test/release-docs.test.js +130 -0
  123. package/test/release-notes.test.js +240 -0
  124. package/test/release-workflow.test.js +93 -0
  125. package/test/route-policy.test.js +113 -0
  126. package/test/scope-distill-stamp.test.js +135 -0
  127. package/test/security-doc.test.js +139 -0
  128. package/test/sensitive-scan.test.js +44 -0
  129. package/test/serve.test.js +1 -1
  130. package/test/session-usage.test.js +308 -0
  131. package/test/store-write-path-columns.test.js +191 -0
  132. package/test/summarize.test.js +176 -0
  133. package/test/supersede-state-tracking.test.js +4 -4
  134. package/test/tools.test.js +16 -1
  135. package/test/workflow-guards.test.js +156 -0
@@ -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
- // 传入键名、从生效配置取值;sleep 无回退重试,档位被拒时测试是事前唯一
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,45 @@ 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
+ // v0.9.0 复验修订:首屏加 group.mo(M.O. 独立组),可点项预算 8→9。这不是
1024
+ // 悄悄放宽——是产品主人点名「别藏在里面」:M.O. 独立成组却仍折在「全部
1025
+ // 设置」后等于换个地方继续藏。≤8 原为 #427 自设软预算,owner 豁免一项。
1026
+ test("settings card folds everything beyond the first screen behind All settings (issue #427)", () => {
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");
1031
+ assert.ok(/key: "group\.basic", items: \["autoInject", "autoSummarize", "autoDream"\]/.test(clientSource),
1032
+ "group.basic must carry exactly the three master switches");
1033
+ assert.ok(!/key: "group\.core", items: \["autoInject/.test(clientSource) && !/key: "group\.dream", items: \["autoDream/.test(clientSource),
1034
+ "basic members must be MOVED out of their old groups (render-once), not filtered at render time");
1035
+ // ② 两层折叠的接线:restGroups 只在 showAll 展开后渲染;「高级」按钮出现在
1036
+ // 「全部设置」之后(同一渲染分支内),showAdv 展开的条目走第二层。
1037
+ assert.ok(/showAll && h\(react\.Fragment, null,\s*restGroups\.map\(renderGroup\)/.test(clientSource),
1038
+ "the remaining groups must render only behind the All settings fold");
1039
+ const allBtn = clientSource.indexOf('t("memory.features.allToggle")');
1040
+ const advBtn = clientSource.indexOf('t("memory.features.advancedToggle")');
1041
+ assert.ok(allBtn >= 0 && advBtn > allBtn, "the advanced fold must be nested after All settings");
1042
+ assert.equal(clientSource.match(/const FEATURE_ADVANCED_BOOLS = \[([^\]]*)\];/)[1].split(",").filter((s) => s.trim()).length, 7,
1043
+ "the advanced second tier must hold exactly the 7 rarer switches the label promises");
1044
+ // ③ 徽标:两类各一套中英文案;判定读 overrides 本地判,不引入后端字段。
1045
+ for (const key of ["memory.features.tagDefault", "memory.features.tagChanged",
1046
+ "memory.features.group.basic", "memory.features.allToggle", "memory.features.advancedToggle"]) {
1047
+ const occurrences = clientSource.split("\"" + key + "\"").length - 1;
1048
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
1049
+ }
1050
+ assert.ok(clientSource.includes("Object.prototype.hasOwnProperty.call(ovr, k)"),
1051
+ "the default/changed badge must be decided from overrides, not a new backend field");
1052
+ });
1053
+
989
1054
  // v0.8.0 冲突集中处理:状态页的待确认冲突队列(reason + 双方内容对比 +
990
1055
  // 保留 A/B/仅标记动作),resolveConflictPending 的第一个前端出口。双语齐备。
991
1056
  test("conflict queue ships a central review surface on the status tab", () => {
@@ -1580,3 +1645,446 @@ test("client bundle parses: 面板产物必须是合法 JS(无重复声明等
1580
1645
  const result = spawnSync(process.execPath, ["--check", join(root, "lib/client.js")], { encoding: "utf8" });
1581
1646
  assert.equal(result.status, 0, `lib/client.js 解析失败:\n${result.stderr || result.stdout}`);
1582
1647
  });
1648
+
1649
+ // #399 的两个留存键(distillRecords.enabled / retentionDays)此前只有后端
1650
+ // 白名单与 docs/CONFIGURATION.md 的「面板可启停」,面板里根本没有这一行——
1651
+ // 文档承诺了一个够不着的开关。补上 UI 之后,这条锁住四件容易被「简化」掉的事:
1652
+ // ① 两个键真的进了渲染清单(enabled 在组里、retentionDays 在数值清单里);
1653
+ // ② 数值项的草稿必须从 effective 初始化——漏掉这一句,行会永远显示空值,
1654
+ // 用户以为配置丢了(#191 的睡眠路由键踩过同一个坑,见 :738 那条注释);
1655
+ // ③ 天数行必须由父开关门控(关着不留存,改天数没有意义);
1656
+ // ④ 非整数在本地就被挡下并把草稿退回生效值——留着非法文本会让人以为改成功了。
1657
+ test("distill retention switches are reachable from the panel (#399)", () => {
1658
+ // 认「组里有没有」不要用 `"key"]` 这种子串——`eff["distillRecords.enabled"]`
1659
+ // 也长这样,把组里那一项删掉它照样命中(变异验证实测:删项后仍全绿)。
1660
+ // 必须把 core 组的 items 数组抠出来单独看。
1661
+ const coreGroup = clientSource.match(/key: "group\.core", items: \[([\s\S]*?)\]/);
1662
+ assert.ok(coreGroup, "the core group must be declared");
1663
+ assert.ok(
1664
+ coreGroup[1].includes('"distillRecords.enabled"'),
1665
+ "distillRecords.enabled must be an item of the core group, not merely referenced elsewhere"
1666
+ );
1667
+ assert.ok(
1668
+ /const FEATURE_NUMBERS = \["distillRecords\.retentionDays"\]/.test(clientSource),
1669
+ "retentionDays must be in the numeric key list"
1670
+ );
1671
+ assert.ok(
1672
+ /for \(const key of FEATURE_NUMBERS\) numDraft\[key\] = String\(e\[key\] \?\? ""\);/.test(clientSource)
1673
+ && /setNums\(numDraft\);/.test(clientSource),
1674
+ "the numeric draft must be seeded from effective on mount, or the row always renders empty"
1675
+ );
1676
+ assert.ok(
1677
+ /const distillSub = eff\["distillRecords\.enabled"\] && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*numRow\("distillRecords\.retentionDays"\)/.test(clientSource),
1678
+ "the retention-days row must be gated on the parent switch and live in its sub-block"
1679
+ );
1680
+ // 定义了不等于接线了:把 distillSub 从组尾子块里摘掉,上面每条断言照样全绿
1681
+ // (变异验证实测:摘掉后仍全绿)。子块是在 items.map 之后按组尾追加的,所以必须确认
1682
+ // 它真的出现在那一个渲染点上。
1683
+ const coreTail = clientSource.match(/g\.key === "group\.core" && h\(react\.Fragment, null, ([^)]*)\)/);
1684
+ assert.ok(coreTail, "the core group must append its sub-blocks at the group tail");
1685
+ assert.ok(
1686
+ /\bdistillSub\b/.test(coreTail[1]),
1687
+ "distillSub must be rendered at the core group tail, not merely declared above"
1688
+ );
1689
+ assert.ok(
1690
+ /if \(!\/\^\\d\+\$\/\.test\(raw\)\) \{\s*\n\s*setNums\(\(c\) => \(\{ \.\.\.c, \[key\]: String\(eff\[key\] \?\? ""\) \}\)\);\s*\n\s*return;\s*\n\s*\}/.test(clientSource),
1691
+ "a non-integer draft must be rejected locally and reverted to the effective value"
1692
+ );
1693
+ for (const key of [
1694
+ "memory.features.distillRecords.enabled",
1695
+ "memory.features.distillRecords.enabled.hint",
1696
+ "memory.features.distillRecords.retentionDays",
1697
+ "memory.features.distillRecords.retentionDays.hint"
1698
+ ]) {
1699
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
1700
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
1701
+ }
1702
+ });
1703
+
1704
+ test("a rejected feature write is visible and reverts the draft", () => {
1705
+ // 锁的是「保存失败从不显示」:错误提示原先只有一个座位、且带 `!state` 条件
1706
+ // (首屏加载失败才渲染)——state 一到手,此后每次写入失败都静默,输入框里
1707
+ // 留着没保存的值。整数项把「越界」变成可达路径后,人能在面板上看到 99999
1708
+ // 却以为它生效了。
1709
+ // 认「座位存在」不能只看 setSaveError 出现几次(函数里调它、渲染处读它,
1710
+ // 两处都在就算命中),要把写入路径与渲染路径分别钉住。
1711
+ assert.ok(
1712
+ clientSource.includes('const [saveError, setSaveError] = useState("");'),
1713
+ "save failures need their own state slot"
1714
+ );
1715
+ // 渲染座位必须在主卡片里(描述行之后、运行时块之前),而不是被 !state 挡住。
1716
+ const descAt = clientSource.indexOf('h("div", { className: "mneme-set-desc" }, t("memory.features.desc"))');
1717
+ const seatAt = clientSource.indexOf('saveError && h("div", { className: "mneme-mainterr" }, saveError)');
1718
+ const provAt = clientSource.indexOf("h(RuntimeProvisionBlock, { t })");
1719
+ assert.ok(descAt > 0 && seatAt > descAt, "the save-error seat must live in the main card body");
1720
+ assert.ok(provAt > seatAt, "the seat must sit in the card header area, before the runtime block");
1721
+ const earlyReturn = clientSource.match(/if \(error && !state\) return[\s\S]{0,300}?error\)\);/);
1722
+ assert.ok(earlyReturn, "the load-failure early return must still be there");
1723
+ assert.ok(!earlyReturn[0].includes("saveError"), "the save-error seat must not ride the load-failure branch");
1724
+
1725
+ // 写入路径:服务端 400 的 body 带着键名与范围(api.js 那条 400 的设计),
1726
+ // 要读出来;失败要能被调用方看见,所以返回值必须是真假。
1727
+ const putBody = clientSource.match(/const put = async \(patch\) => \{([\s\S]*?)\r?\n \};/);
1728
+ assert.ok(putBody, "put must be declared");
1729
+ assert.ok(putBody[1].includes("const detail = await res.json()"), "the server error body must be read");
1730
+ assert.ok(putBody[1].includes('throw new Error(detail || ("HTTP " + res.status));'), "the body must win over the bare status");
1731
+ assert.ok(putBody[1].includes("return true;") && putBody[1].includes("return false;"), "put must report success");
1732
+ assert.ok(putBody[1].includes("setSaveError(`"), "a rejected write must set the visible error");
1733
+
1734
+ // 两个提交路径都要在同一处退回生效值:只报错不退,框里的值仍与在用的值分家。
1735
+ assert.ok(clientSource.includes("put({ [key]: raw }).then((ok) => {"), "a rejected string draft must be revertible");
1736
+ assert.ok(clientSource.includes("put({ [key]: Number(raw) }).then((ok) => {"), "a rejected number draft must be revertible");
1737
+ assert.ok((clientSource.split("if (!ok) setStrs(").length - 1) >= 1, "the string draft must revert on rejection");
1738
+ assert.ok((clientSource.split("if (!ok) setNums(").length - 1) >= 1, "the number draft must revert on rejection");
1739
+
1740
+ const occurrences = clientSource.split('"memory.features.saveFailed"').length - 1;
1741
+ assert.ok(occurrences >= 2, `i18n key memory.features.saveFailed must exist in both zh and en (got ${occurrences})`);
1742
+ });
1743
+
1744
+ // ── M.O.(Modus Operandi)面板的数据面 ────────────────────────────────────────
1745
+ // 第一版面板拿硬编码的 PIPELINE_MOCK 验证形状与交互(服务端管线当时还没落地)。
1746
+ // 接上真数据之后那份常量必须消失:留着它,面板随时可能退回展示一份并不存在的
1747
+ // 工作流——而这正是这一刀要修掉的东西。这里锁「没有」比锁「有什么」更要紧。
1748
+ test("M.O. panel carries no mock pipeline data", () => {
1749
+ assert.equal(
1750
+ clientSource.includes("PIPELINE_MOCK"),
1751
+ false,
1752
+ "the hardcoded PIPELINE_MOCK must be gone now that the panel reads the server"
1753
+ );
1754
+ });
1755
+
1756
+ // 四个座位——会话头图标、会话头浮层、右栏 tab、对话行结算卡——必须读同一份投影。
1757
+ // 分家的事故长这样:会话头说有 3 条候选、右栏说有 5 条,而两个数字都对、没人查得出来。
1758
+ // 所以缓存只许有一处(usePipeline),其余要么从它取,要么经 PipelineBrowser 取。
1759
+ test("M.O. seats share a single pipeline document", () => {
1760
+ assert.equal(
1761
+ (clientSource.match(/function usePipeline\(/g) || []).length,
1762
+ 1,
1763
+ "usePipeline must be defined exactly once — one cache, every seat"
1764
+ );
1765
+ const bodyOf = (name) => {
1766
+ const start = clientSource.indexOf(`function ${name}(`);
1767
+ assert.ok(start > -1, `${name} must exist`);
1768
+ const next = clientSource.slice(start + 1).indexOf("\n function ");
1769
+ return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
1770
+ };
1771
+ for (const seat of ["PipelineHeaderEntry", "TurnRunCard"]) {
1772
+ assert.ok(
1773
+ bodyOf(seat).includes("usePipeline(project, null)"),
1774
+ `${seat} must read the shared pipeline document`
1775
+ );
1776
+ }
1777
+ // 另两个座位不再自己调 hook:它们把 project 交给 PipelineBrowser,由它按 openId 决定
1778
+ // 读清单还是读某一条。hook 不能条件调用,所以这道分叉必须落在**组件之间**而不是
1779
+ // 一个组件里的 if/else——后者会在点开那一下炸成「Rendered more hooks than during
1780
+ // the previous render」,而那是运行期才看得见的错。
1781
+ assert.ok(
1782
+ bodyOf("PipelinePane").includes("h(PipelineBrowser, { t, project })"),
1783
+ "PipelinePane must delegate to PipelineBrowser"
1784
+ );
1785
+ });
1786
+
1787
+ // 清单与图**同屏**。这条锁的形状换过两次,两次的教训都写在这里,免得再翻回去:
1788
+ // 一进来就是图 —— 没有名字、没有落点,要的是一串有名字的条目;
1789
+ // 清单 ⟷ 图两屏来回切 —— 条目有了,但画布藏在一次点击后面、界面上没有任何提示,
1790
+ // 反馈连着两次说「看不到画布和节点」。
1791
+ // 所以锁的是:清单行渲染名字/作用域/规模,**且** 图与清单在同一个组件里同时出现。
1792
+ test("M.O. browser shows the named list and the canvas on one screen", () => {
1793
+ const bodyOf = (name) => {
1794
+ const start = clientSource.indexOf(`function ${name}(`);
1795
+ assert.ok(start > -1, `${name} must exist`);
1796
+ const next = clientSource.slice(start + 1).indexOf("\n function ");
1797
+ return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
1798
+ };
1799
+ const list = bodyOf("PipelineWorkflowList");
1800
+ assert.ok(list.includes("row.title || row.id"), "each row must show a name");
1801
+ assert.ok(list.includes("memory.actions.scope.project"), "each row must show its scope");
1802
+ assert.ok(list.includes("memory.actions.list.meta"), "each row must show its size");
1803
+ const browser = bodyOf("PipelineBrowser");
1804
+ assert.ok(browser.includes("PipelineWorkflowList"), "the named list must be on screen");
1805
+ assert.ok(
1806
+ browser.includes("h(PipelineView, { t, data: detailDoc })"),
1807
+ "the canvas must be on the SAME screen — not behind a click"
1808
+ );
1809
+ assert.ok(browser.includes("rows.length ? rows[0].id : null"), "the first workflow must be expanded by default");
1810
+ });
1811
+
1812
+ // 面板读的是服务端投影,不是本地算的:工作流本体住仓库、投影住服务端,
1813
+ // 面板只负责画。路由一旦改了名字、或者项目根忘了带,这条会先红。
1814
+ test("M.O. panel reads the server projection", () => {
1815
+ assert.match(
1816
+ clientSource,
1817
+ /"\/api\/dsh-mneme\/pipeline" \+/,
1818
+ "the panel must fetch the pipeline route"
1819
+ );
1820
+ // 项目根是**客户端从会话读出来带过去的**,不是服务端猜的:插件侧的 ctx 上没有 cwd
1821
+ // (只有 logger / llm / reflect / webServer / inject / commands),猜不出来。
1822
+ assert.ok(
1823
+ clientSource.includes('params.push("project=" + encodeURIComponent(project))'),
1824
+ "the panel must pass the session's project root to the route"
1825
+ );
1826
+ assert.ok(
1827
+ clientSource.includes("function sessionProject(ctx, sessionId)"),
1828
+ "the session cwd lookup must exist and stay named"
1829
+ );
1830
+ // 两条来源的先后:会话列表快照(byId[sessionId].cwd)是宿主给客户端的公开形状,
1831
+ // sessions.get() 是宿主侧写法、客户端不一定有。顺序反了不会报错,只会让面板静悄悄
1832
+ // 退回全局层——那跟「本项目还没有工作流」长得一模一样,是最难查的一类错。
1833
+ // 而且两条必须各兜各的异常:一条抛了不能把另一条也带走,否则同样表现成「项目未知」。
1834
+ const sessionProjectBody = clientSource.slice(
1835
+ clientSource.indexOf("function sessionProject("),
1836
+ clientSource.indexOf("function layoutDag(")
1837
+ );
1838
+ assert.ok(
1839
+ sessionProjectBody.indexOf("sessions.list.getSnapshot") <
1840
+ sessionProjectBody.indexOf("typeof sessions.get"),
1841
+ "the host's public shape (list snapshot) must be tried before the host-side accessor"
1842
+ );
1843
+ const betweenLookups = sessionProjectBody.slice(
1844
+ sessionProjectBody.indexOf("sessions.list.getSnapshot"),
1845
+ sessionProjectBody.indexOf("typeof sessions.get")
1846
+ );
1847
+ assert.ok(
1848
+ betweenLookups.includes("catch (err)"),
1849
+ "each lookup must carry its own catch — a throwing accessor must not take out the next one"
1850
+ );
1851
+ });
1852
+
1853
+ // 右栏 tab 的正文单独包了一层具名组件,而不是把 hook 直接塞进 slots.register 的回调里:
1854
+ // 那个回调是不是当组件渲染由宿主决定,赌错就是「Rendered more hooks than during the
1855
+ // previous render」这种只在运行时炸、测试看不见的错。
1856
+ test("right-pane tab renders a named component instead of a bare hook callback", () => {
1857
+ assert.match(clientSource, /function PipelinePane\(/, "PipelinePane must exist");
1858
+ assert.ok(
1859
+ clientSource.includes(
1860
+ "}, (props) => h(PipelinePane, { t, project: sessionProject(ctx, props && props.sessionId) }));"
1861
+ ),
1862
+ "the tab callback must delegate to the named PipelinePane component, with the session's project"
1863
+ );
1864
+ });
1865
+
1866
+ // 「仓库里还没有工作流」与「一条运行记录都没有」是两句不同的话,都得中英齐全——
1867
+ // i18n 掉一边,界面上就是一行 key 裸奔。作用域那三句尤其不能缺:清单行上「项目」
1868
+ // 与「全局」是唯一的来源提示,缺了它,人分不清手里这条是本项目的还是跟着插件来的。
1869
+ test("M.O. empty-state, list and scope copy exist in both languages", () => {
1870
+ for (const key of [
1871
+ "memory.actions.empty.title",
1872
+ "memory.actions.empty.body",
1873
+ "memory.actions.noRuns",
1874
+ "memory.actions.zoom.in",
1875
+ "memory.actions.zoom.out",
1876
+ "memory.actions.zoom.fit",
1877
+ "memory.actions.list.heading",
1878
+ "memory.actions.list.count",
1879
+ "memory.actions.list.meta",
1880
+ "memory.actions.scope.project",
1881
+ "memory.actions.scope.global",
1882
+ "memory.actions.scope.none",
1883
+ "memory.actions.broken"
1884
+ ]) {
1885
+ const hits = (clientSource.match(new RegExp(`"${key.replace(/\./g, "\\.")}":`, "g")) || []).length;
1886
+ assert.equal(hits, 2, `${key} must exist in both zh and en (got ${hits})`);
1887
+ }
1888
+ });
1889
+
1890
+ // 画布能放大缩小。锁的重点不是「有没有缩放」,而是滚轮那一条必须**自己挂非被动监听**:
1891
+ // React 的 onWheel 挂在 root 上且是 passive 的,preventDefault 会被浏览器忽略,
1892
+ // 于是 Ctrl+滚轮既缩放画布又缩放整页,谁也没拦住——这个错本地测不出来,只在真浏览器里响。
1893
+ test("M.O. canvas zooms: buttons plus a non-passive, modifier-gated wheel listener", () => {
1894
+ const bodyOf = (name) => {
1895
+ const start = clientSource.indexOf(`function ${name}(`);
1896
+ assert.ok(start > -1, `${name} must exist`);
1897
+ const next = clientSource.slice(start + 1).indexOf("\n function ");
1898
+ return clientSource.slice(start, next > -1 ? start + 1 + next : clientSource.length);
1899
+ };
1900
+ const view = bodyOf("PipelineView");
1901
+ assert.ok(view.includes("PipelineZoomBar"), "the zoom control must be rendered");
1902
+ assert.ok(view.includes("{ passive: false }"), "the wheel listener must be non-passive, or preventDefault is ignored");
1903
+ assert.ok(view.includes("ev.ctrlKey") && view.includes("ev.metaKey"), "plain wheel must stay a scroll, not a zoom");
1904
+ assert.ok(view.includes('width: (zoom * 100) + "%"'), "zoom must change the layout width so the container can scroll to it");
1905
+ const bar = bodyOf("PipelineZoomBar");
1906
+ assert.ok(bar.includes("memory.actions.zoom.in"), "zoom in must be reachable by button, not only by wheel");
1907
+ assert.ok(bar.includes("memory.actions.zoom.out"), "zoom out must be reachable by button");
1908
+ });
1909
+
1910
+ // 「tab 的类型」与「tab 的正文」必须是两件互不相干的事,而且类型注册要幂等。
1911
+ // 为什么会栽(2026-10-16 真机上现形):宿主对重复的类型 id 会抛
1912
+ // (sidebarRight: tab type id "…" is already registered),而类型注册挂在宿主服务上、
1913
+ // 插件重挂(刷新 / 热重载 / 会话重建)时**留着**,正文座位却是挂插件 scope、随插件回收的。
1914
+ // 两件事原来同一个 try —— 第二次挂载时那一抛把正文一起带走,界面上只剩宿主的 fallback
1915
+ // 「这类内容还没有可用的查看方式」,错误只在浏览器控制台里,界面上零痕迹。
1916
+ test("M.O. tab type registration is idempotent and isolated from the pane body", () => {
1917
+ const typeAt = clientSource.indexOf("id: PIPELINE_ACTIONS_TAB_ID,");
1918
+ const bodyAt = clientSource.indexOf('ctx.slots.inject("sidebar.right.pane.tab"');
1919
+ assert.ok(typeAt > -1, "the tab type registration must exist");
1920
+ assert.ok(bodyAt > typeAt, "the pane body registration must exist, after the type");
1921
+ assert.ok(
1922
+ clientSource.includes("tabs.get(PIPELINE_ACTIONS_TAB_ID)"),
1923
+ "the type registration must be idempotent — a re-mount must not re-register the id"
1924
+ );
1925
+ assert.ok(
1926
+ clientSource.slice(typeAt, bodyAt).includes("catch (err)"),
1927
+ "the type registration must carry its own catch — its failure must not take out the pane body"
1928
+ );
1929
+ });
1930
+
1931
+ // 回归锁:M.O. 段里每个 .mneme-pipe-* 类名在样式表里只许有**一条**规则。
1932
+ // 为什么会栽在这里(2026-10-16,真机上才现形):新写的详情屏容器与既有的「节点详情」面板
1933
+ // 撞了同名 .mneme-pipe-detail —— 后写的 flex:1 赢下冲突,先写的 max-height:180px; overflow:auto
1934
+ // 却留着,于是详情屏被钉在 180px、里面的画布 flex:1 分到 0 像素,屏幕上只剩空白加一条滚动条。
1935
+ // 只查颜色的 contrast.test.js 抓不到这类撞车;它只有在屏幕上看才现形,所以要在这里钉住。
1936
+ test("M.O. class names do not collide in the stylesheet", () => {
1937
+ const declared = new Map();
1938
+ for (const m of clientSource.matchAll(/"\.mneme-pipe-([a-z0-9-]+)\{/g)) {
1939
+ declared.set(m[1], (declared.get(m[1]) || 0) + 1);
1940
+ }
1941
+ const dupes = [...declared].filter(([, n]) => n > 1).map(([k, n]) => `.mneme-pipe-${k}×${n}`);
1942
+ assert.deepEqual(dupes, [], `duplicate .mneme-pipe-* rule(s): ${dupes.join(", ")}`);
1943
+ // 扫描量也得钉:正则一旦匹配不到东西,上面那条会静默变绿(例如样式表被整块挪走)。
1944
+ assert.ok(declared.size >= 20, `the scan must actually see the pipeline stylesheet (saw ${declared.size})`);
1945
+ });
1946
+
1947
+ // 回归锁(v0.9.0 复验轮沉淀):面板的两种「静默消失」假绿形状一起钉住。
1948
+ // ① i18n 缺定义:既有的「每键恰好两次」锁只统计**字典里存在**的键,一个只有
1949
+ // 用法、零字典行的键在计数里整个消失——本轮就查出 4 个这种键(zoom.value 自
1950
+ // 基线即断)。所以这里反向查:每一处用法都必须能找到定义行。三种用法形状:
1951
+ // 字面量 t("k")、模板 t(`memory.features.${g.key}`) 按 FEATURE_GROUPS 的组键
1952
+ // 展开、模板 t(`memory.features.${k}`) 按各 items 旗标键展开(boolRow 的
1953
+ // name/hint 各一路)。不做全量动态键解析——拼接形态的键在此设防范围外。
1954
+ // ② 图标名未注册:Icon 对查不到的名字 return null(无回退、无告警),闸门照绿
1955
+ // 而屏幕上图标没了。本轮 star 入口就是这么哑掉的。这里钉「每个用到的名字都在
1956
+ // ICON_PATHS 注册表里」+ 提取与注册表的最低数量(防正则空转的静默绿)。
1957
+ test("panel copy and icons must not silently vanish (missing dict lines, unregistered icon names)", () => {
1958
+ const defined = (key) => clientSource.includes('"' + key + '":');
1959
+
1960
+ // ①-a 字面量用法(右括号紧贴字符串,排除 "前缀" + 变量 拼接形态)
1961
+ const usedKeys = new Set();
1962
+ for (const m of clientSource.matchAll(/\bt\("([a-zA-Z0-9_.]+)"\)/g)) usedKeys.add(m[1]);
1963
+ assert.ok(usedKeys.size >= 300, `the literal scan must actually see the panel's t() calls (saw ${usedKeys.size})`);
1964
+ // ①-b 模板展开:组键 与 旗标键(label + .hint 两路)
1965
+ const groupKeys = [...clientSource.matchAll(/key: "(group\.[a-z]+)"/g)].map((m) => m[1]);
1966
+ assert.ok(groupKeys.length >= 5, `FEATURE_GROUPS must be parseable (saw ${groupKeys.length} groups)`);
1967
+ for (const g of groupKeys) usedKeys.add("memory.features." + g);
1968
+ const flagKeys = [...new Set([...clientSource.matchAll(/items: \[((?:[^\]]|\n)*)\]/g)]
1969
+ .flatMap((m) => [...m[1].matchAll(/"([a-zA-Z0-9_.]+)"/g)].map((x) => x[1])))];
1970
+ assert.ok(flagKeys.length >= 20, `group items must be parseable (saw ${flagKeys.length})`);
1971
+ for (const k of flagKeys) { usedKeys.add("memory.features." + k); usedKeys.add("memory.features." + k + ".hint"); }
1972
+
1973
+ const missing = [...usedKeys].filter((k) => !defined(k)).sort();
1974
+ assert.deepEqual(missing, [], `用而无典的 i18n 键(界面会裸奔出键名):${missing.join(", ")}`);
1975
+
1976
+ // ② 图标名:注册表形状(顶层对象、六空格缩进)+ Icon 表达式与 feedRow 首参的提取
1977
+ const registered = new Set([...clientSource.matchAll(/^\s{6}([a-zA-Z]+): \[\[/gm)].map((m) => m[1]));
1978
+ assert.ok(registered.size >= 10, `the ICON_PATHS registry must be parseable (saw ${registered.size})`);
1979
+ const iconNames = new Set();
1980
+ for (const m of clientSource.matchAll(/h\(Icon, \{ name: ([^}]+?) [,}]/g)) {
1981
+ const expr = m[1];
1982
+ const direct = expr.match(/^"([a-zA-Z]+)"$/);
1983
+ if (direct) { iconNames.add(direct[1]); continue; }
1984
+ // 只取三元分支的纯字母串;"===" 比较对象前面是运算符不是 ? : ,不会误伤
1985
+ for (const b of expr.matchAll(/[?:]\s*"([a-zA-Z]+)"/g)) iconNames.add(b[1]);
1986
+ }
1987
+ for (const m of clientSource.matchAll(/feedRow\(\s*"([a-zA-Z]+)"/g)) iconNames.add(m[1]);
1988
+ assert.ok(iconNames.size >= 8, `icon usage must be parseable (saw ${iconNames.size})`);
1989
+ const noIcon = [...iconNames].filter((n) => !registered.has(n)).sort();
1990
+ assert.deepEqual(noIcon, [], `Icon 静默 return null 的未注册名:${noIcon.join(", ")}`);
1991
+ });
1992
+
1993
+ // 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
1994
+ // + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
1995
+ test("persona page ships settings card, master switch and i18n (beta v1)", () => {
1996
+ // 开关进 FEATURE_GROUPS 的 enhance 组(行为开关非座位闸门)
1997
+ assert.ok(
1998
+ /group\.enhance", items: \[(?:[^\]]|\n)*"personaEnabled"\]/.test(clientSource),
1999
+ "FEATURE_GROUPS group.enhance must carry personaEnabled"
2000
+ );
2001
+ // i18n 键中英双语齐备(occurrences ≥ 2 模式;label + .hint 两路由字典锁统一覆盖)
2002
+ for (const key of [
2003
+ "memory.features.personaEnabled",
2004
+ "memory.features.personaEnabled.hint",
2005
+ "memory.settings.persona",
2006
+ "memory.settings.personaHint",
2007
+ "memory.settings.personaName",
2008
+ "memory.settings.personaPersonality",
2009
+ "memory.settings.personaReplyStyle",
2010
+ "memory.settings.personaSave",
2011
+ "memory.settings.personaSaved",
2012
+ "memory.settings.personaEnabledToggle",
2013
+ "memory.settings.personaView",
2014
+ "memory.settings.personaViewHint",
2015
+ "memory.settings.personaViewEmpty"
2016
+ ]) {
2017
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2018
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2019
+ }
2020
+ // 设置卡接线:保存走 PUT /persona、卡内开关改 enabled 即保存、只读卡渲染
2021
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/persona"'), "persona card must talk to /persona");
2022
+ assert.ok(clientSource.includes("method: \"PUT\""), "persona save must PUT");
2023
+ assert.ok(clientSource.includes("t(\"memory.settings.personaView\")"), "read-only injected-view card must exist");
2024
+ // 双闸门提示:hint 文案必须说明「总闸与卡内开关同时打开才生效」
2025
+ assert.ok(
2026
+ clientSource.includes("memory.features.personaEnabled.hint"),
2027
+ "the master-switch hint must document the two-gate semantics"
2028
+ );
2029
+ });
2030
+
2031
+ // v1.2 多卡回归:卡列表 CRUD 可见、字段标签渲染为可见元素(v1.1 反馈:
2032
+ // aria-label 看不见)、保存失败必须冒可见错误(v1 静默 catch 吞 404 教训)。
2033
+ test("persona card is multi-card with visible field labels and save-failure error", () => {
2034
+ for (const key of [
2035
+ "memory.settings.personaDefaultCard",
2036
+ "memory.settings.personaAdd",
2037
+ "memory.settings.personaAddPlaceholder",
2038
+ "memory.settings.personaEditingDefault",
2039
+ "memory.settings.personaEditingFor",
2040
+ "memory.settings.personaDeleteCard",
2041
+ "memory.settings.personaSaveFailed",
2042
+ "memory.settings.personaEnabledOn",
2043
+ "memory.settings.personaEnabledOff"
2044
+ ]) {
2045
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2046
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2047
+ }
2048
+ // 多卡接线:加卡/删卡/切换/保存全链函数存在
2049
+ for (const fn of ["addPersonaCard", "removePersonaCard", "savePersonaCards", "setPersonaEditing"]) {
2050
+ assert.ok(clientSource.includes(fn), `multi-card wiring must include ${fn}`);
2051
+ }
2052
+ // 可见字段标签:三个字段名的 t() 调用出现在 label 元素(mneme-featnumlabel)
2053
+ const labelBlock = clientSource.match(/mneme-featnumlabel[^]{0,120}personaName/);
2054
+ assert.ok(labelBlock, "persona field labels must be visible (featnumlabel class), not aria-only");
2055
+ // 保存失败提示:personaError 状态有渲染路径(非仅 setState)
2056
+ assert.ok(clientSource.includes("t(\"memory.settings.personaSaveFailed\")"), "save failure must render an error line");
2057
+ });
2058
+
2059
+ // 回归锁(复验实测踩中):面板「检测更新」的状态名必须与后端 classify
2060
+ // (src/version-check.js)逐字对齐。此前前端误写 "latest"/"up_to_date"(后端
2061
+ // 从未返回过),检测成功也掉进 error 兜底——按钮永远报「检测失败」。
2062
+ // 这条缝两端各有测试(version-check.test.js 测 classify、面板测试测渲染),
2063
+ // 但契约本身没人管。锁三件事:①前端比较的字面量是后端真实返回值;②后端
2064
+ // classify 的返回集合没有前端漏接的成员;③apiFetch 的路由路径与后端注册一致。
2065
+ test("update-check status names match the backend classify contract verbatim", () => {
2066
+ const vcSource = readFileSync(join(root, "src/version-check.js"), "utf8");
2067
+ // ① 后端 classify 的全部返回字面量
2068
+ const backendStatuses = new Set(
2069
+ [...vcSource.matchAll(/return "([a-z-]+)";/g)].map((m) => m[1])
2070
+ );
2071
+ assert.ok(backendStatuses.has("up-to-date") && backendStatuses.has("outdated") && backendStatuses.has("ahead") && backendStatuses.has("unknown"),
2072
+ `classify must return the known status set (saw ${[...backendStatuses].join(", ")})`);
2073
+ // ② 前端 checkResultText 比较的每个状态名都必须 ∈ 后端集合(防再发明新名)
2074
+ const iStart = clientSource.indexOf("function checkResultText");
2075
+ assert.ok(iStart > 0, "checkResultText must exist");
2076
+ // 函数块取到下一个同级 function 声明为止:indexOf("}") 会切在首个 if 的
2077
+ // 右括号,比较分支被截断成 1 个(2026-10-17 实测)。
2078
+ const iEnd = clientSource.slice(iStart).search(/\n {6}function /);
2079
+ const block = clientSource.slice(iStart, iEnd > 0 ? iStart + iEnd : undefined);
2080
+ const compared = [...block.matchAll(/updateInfo\.status === "([a-zA-Z_-]+)"/g)].map((m) => m[1]);
2081
+ assert.ok(compared.length >= 2, `the panel must compare real statuses (saw ${compared.length})`);
2082
+ const unknown = [...new Set(compared)].filter((s) => !backendStatuses.has(s));
2083
+ assert.deepEqual(unknown, [], `前端比较了后端不会返回的状态名(检测成功也会显示失败):${unknown.join(", ")}`);
2084
+ // ③ 路由契约:前端 fetch 的路径必须与后端注册的 exact path 一致
2085
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/version-check")'),
2086
+ "the panel must call the registered version-check route");
2087
+ const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
2088
+ assert.ok(apiSource.includes('path: "/api/dsh-mneme/version-check"'),
2089
+ "the backend must register the version-check route");
2090
+ });
@@ -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
+ });