@modusensus/dsh-mneme 0.9.3 → 0.9.4

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.
@@ -1813,3 +1813,228 @@ test("v0.9.0: scopeEnabled 关时蒸馏写入一个 scope 字段都不加(老
1813
1813
  assert.equal(row.workspace_scope_source, undefined);
1814
1814
  }
1815
1815
  });
1816
+
1817
+ // --- Issue #493:转录围栏 / 失败护栏 / 失败行可诊断性 ------------------------------
1818
+ // 现场:转录裸放 user 位 → 模型当成「该我回话了」接着往下写 → 输出无 `[` →
1819
+ // 解析器括号早退判失败 → 不推进游标 → 下一个 turn/end 同文同温重试 → 同一窗口
1820
+ // 确定性失败、窗口越攒越大(报告者 20 条失败 input 8194→11118 单调递增)。
1821
+
1822
+ test("#493 提示构造:转录包进围栏,user 侧重申「第三方提炼员」,system 不动", async () => {
1823
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
1824
+ await eventHandler(events)(
1825
+ {
1826
+ id: "s493-prompt",
1827
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1828
+ events: [userMessage("把这段对话蒸馏一下", 1), { seq: 2, type: "turn/end" }]
1829
+ },
1830
+ { seq: 2, type: "turn/end" }
1831
+ );
1832
+
1833
+ const [system, user] = calls[0].messages;
1834
+ assert.equal(system.role, "system");
1835
+ assert.ok(system.content[0].text.includes("原子记忆"), "system 提示词保持原样(既有回归断言依赖它)");
1836
+ assert.equal(user.role, "user");
1837
+ const body = user.content[0].text;
1838
+ // 角色句与围栏都必须落在 user 侧:模型对「这段文本是什么」的判断由它所在的
1839
+ // 那条消息决定,只写进 system 救不回来(实测同段转录只加 system 约束仍 0/2 出 JSON)。
1840
+ assert.ok(body.includes("第三方提炼员"), "user 侧必须重申角色,否则模型接着对话往下写");
1841
+ assert.ok(body.includes("不是下面这段会话的参与者"), "角色句要明说不参与,不能只说「请输出 JSON」");
1842
+ const open = body.indexOf("<<<会话转录开始>>>");
1843
+ const close = body.indexOf("<<<会话转录结束>>>");
1844
+ assert.ok(open > 0 && close > open, "转录必须被围栏包住");
1845
+ assert.ok(body.slice(open, close).includes("把这段对话蒸馏一下"), "原文仍在围栏内");
1846
+ assert.ok(body.indexOf("第三方提炼员") < open, "角色句在围栏之前");
1847
+ });
1848
+
1849
+ test("#493 提示构造:围栏是确定性的,重试收到的转录与首次逐字节相同", async () => {
1850
+ let attempt = 0;
1851
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
1852
+ stream() {
1853
+ const output = attempt++ === 0 ? "不按格式的一整段中文点评" : "[]";
1854
+ return (async function* () {
1855
+ yield { type: "block-start", block: { type: "text" } };
1856
+ yield { type: "text-delta", delta: output };
1857
+ yield { type: "finish", kind: "ok" };
1858
+ })();
1859
+ }
1860
+ });
1861
+ const handler = eventHandler(events);
1862
+ const session = {
1863
+ id: "s493-deterministic",
1864
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1865
+ events: [userMessage("同一窗口重试", 1), { seq: 2, type: "turn/end" }]
1866
+ };
1867
+ await handler(session, { seq: 2, type: "turn/end" });
1868
+ await handler(session, { seq: 2, type: "turn/end" });
1869
+ assert.equal(calls.length, 2);
1870
+ const first = calls[0].messages.find((m) => m.role === "user").content[0].text;
1871
+ const second = calls[1].messages.find((m) => m.role === "user").content[0].text;
1872
+ // 围栏里不能有时间戳/随机数:否则「同文同温」这条前提就不成立,护栏阈值
1873
+ // 会变成对着一堆各不相同的输入计数。
1874
+ assert.equal(first, second, "重试必须收到逐字节相同的转录");
1875
+ });
1876
+
1877
+ test("#493 计量口径:distillChars 只数转录正文,不被围栏与角色句撑大", async () => {
1878
+ // 这条是护栏的邻居而非护栏本身:#493 给 user 消息加了约 100 字的固定包裹,
1879
+ // 若 distillChars 仍按「消息总字符数」算,4 字的窗口会凭空越过任何阈值,
1880
+ // summarizeMinWindowChars 等于被自己的提示词旁路掉。
1881
+ const { events, service } = setup({ summarizeMinWindowChars: 60, distillRateLimitIntervalMs: 0 });
1882
+ await eventHandler(events)(
1883
+ {
1884
+ id: "s493-metric",
1885
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1886
+ events: [userMessage("短", 1), { seq: 2, type: "turn/end" }]
1887
+ },
1888
+ { seq: 2, type: "turn/end" }
1889
+ );
1890
+ const row = service.listLlmAudits()[0];
1891
+ assert.equal(row.error_message, "window-too-small", "4 字正文不得被包裹撑过 60 字阈值");
1892
+ assert.equal(row.metadata.distillChars, 4, "计的是转录正文(「用户:短」= 4 字)");
1893
+ });
1894
+
1895
+ test("#493 失败行可诊断:metadata 带 raw_head / raw_has_bracket / streak", async () => {
1896
+ const { events, service } = setup({ distillRateLimitIntervalMs: 0 }, {
1897
+ stream() {
1898
+ return (async function* () {
1899
+ yield { type: "block-start", block: { type: "text" } };
1900
+ yield { type: "text-delta", delta: "已按你的要求将值班轮单日上限改为 5,下面是具体改动…" };
1901
+ yield { type: "finish", kind: "ok" };
1902
+ })();
1903
+ }
1904
+ });
1905
+ await eventHandler(events)(
1906
+ {
1907
+ id: "s493-diag",
1908
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1909
+ events: [userMessage("帮我改一下值班轮上限", 1), { seq: 2, type: "turn/end" }]
1910
+ },
1911
+ { seq: 2, type: "turn/end" }
1912
+ );
1913
+
1914
+ const row = service.listLlmAudits()[0];
1915
+ assert.equal(row.status, "error");
1916
+ assert.equal(row.error_message, "invalid summary JSON");
1917
+ // 原先 metadata 恒为 null,90 条历史失败一条原始输出都没留 —— 事后无法回答
1918
+ // 「模型到底吐了什么形状」。raw_has_bracket 是区分「没按格式写」与「格式对但
1919
+ // 内容救不回来」的唯一低成本判据,也正是 #493 现场的关键特征。
1920
+ assert.equal(row.metadata?.parse_failure, true);
1921
+ assert.equal(row.metadata?.raw_has_bracket, false, "这段点评里没有方括号");
1922
+ assert.ok(row.metadata?.raw_len > 0);
1923
+ assert.ok(row.metadata?.raw_head.startsWith("已按你的要求"), "原始输出前缀要留档");
1924
+ assert.equal(row.metadata?.streak, 1);
1925
+ assert.ok(Number.isInteger(row.metadata?.distillChars), "失败行同样带窗口字符数");
1926
+ });
1927
+
1928
+ test("#493 失败护栏默认关:连续失败也不放弃窗口(历史行为逐字节不变)", async () => {
1929
+ const { events, calls } = setup({ distillRateLimitIntervalMs: 0 }, {
1930
+ stream() {
1931
+ return (async function* () {
1932
+ yield { type: "block-start", block: { type: "text" } };
1933
+ yield { type: "text-delta", delta: "永远不是 JSON" };
1934
+ yield { type: "finish", kind: "ok" };
1935
+ })();
1936
+ }
1937
+ });
1938
+ const handler = eventHandler(events);
1939
+ const session = {
1940
+ id: "s493-guard-off",
1941
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1942
+ events: [userMessage("窗口", 1), { seq: 2, type: "turn/end" }]
1943
+ };
1944
+ for (let i = 0; i < 5; i++) await handler(session, { seq: 2, type: "turn/end" });
1945
+ // 默认 0 = 关:这是本仓新行为的既定纪律(AGENTS.md),也是既有 4 个
1946
+ // 「解析失败不消费窗口」用例语义不变的前提。
1947
+ assert.equal(calls.length, 5, "护栏关着时每个 turn/end 仍重试同一窗口");
1948
+ });
1949
+
1950
+ test("#493 失败护栏:连续 N 次失败后强制消费游标跳过该窗口", async () => {
1951
+ let attempt = 0;
1952
+ const { events, calls, service, store, summarizer } = setup(
1953
+ { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
1954
+ {
1955
+ stream() {
1956
+ attempt++;
1957
+ const output = attempt <= 2 ? "确定性失败:一段中文点评" : "[]";
1958
+ return (async function* () {
1959
+ yield { type: "block-start", block: { type: "text" } };
1960
+ yield { type: "text-delta", delta: output };
1961
+ yield { type: "finish", kind: "ok" };
1962
+ })();
1963
+ }
1964
+ }
1965
+ );
1966
+ const handler = eventHandler(events);
1967
+ const session = {
1968
+ id: "s493-guard-on",
1969
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1970
+ events: [userMessage("会被放弃的窗口", 1), { seq: 2, type: "turn/end" }]
1971
+ };
1972
+
1973
+ await handler(session, { seq: 2, type: "turn/end" });
1974
+ assert.equal(calls.length, 1);
1975
+ assert.equal(service.getDistillCursor("s493-guard-on"), undefined, "第一次失败仍不推进游标");
1976
+
1977
+ await handler(session, { seq: 2, type: "turn/end" });
1978
+ assert.equal(calls.length, 2, "阈值内仍重试");
1979
+ // 达到阈值(2):窗口被放弃,游标推进到本窗末尾,下一轮从全新窗口起算。
1980
+ const cursor = service.getDistillCursor("s493-guard-on");
1981
+ assert.equal(cursor?.last_seq, 2, "达到阈值后强制消费游标");
1982
+ const errorRows = service.listLlmAudits().filter((r) => r.status === "error");
1983
+ assert.equal(errorRows.length, 2);
1984
+ // 按 streak 定位而不是按下标:审计表的返回顺序是存储层的事,不该被这条断言锁住。
1985
+ const abandoned = errorRows.find((r) => r.metadata?.streak === 2);
1986
+ assert.equal(abandoned?.metadata?.skipped, "parse-failure-streak", "放弃必须可观测");
1987
+ assert.equal(abandoned?.metadata?.maxRetries, 2);
1988
+ assert.equal(errorRows.find((r) => r.metadata?.streak === 1)?.metadata?.skipped, undefined,
1989
+ "阈值前的那次失败仍按「可重试」记账,不得提前宣告放弃");
1990
+
1991
+ // 游标已消费 → 同一 turn/end 不再重试;新事件开新窗口并正常落库。
1992
+ await handler(session, { seq: 2, type: "turn/end" });
1993
+ assert.equal(calls.length, 2, "被放弃的窗口不再重试");
1994
+ session.events.push(userMessage("新的窗口", 3), { seq: 4, type: "turn/end" });
1995
+ await handler(session, { seq: 4, type: "turn/end" });
1996
+ assert.equal(calls.length, 3, "新窗口正常蒸馏");
1997
+ summarizer.dispose();
1998
+ store.close();
1999
+ });
2000
+
2001
+ test("#493 失败护栏:一次成功即清零计数,不会隔窗累计", async () => {
2002
+ let attempt = 0;
2003
+ const { events, calls, service, store, summarizer } = setup(
2004
+ { summarizeParseFailureMaxRetries: 2, distillRateLimitIntervalMs: 0 },
2005
+ {
2006
+ stream() {
2007
+ // 失败 → 成功 → 失败:第 3 次是「新一轮的第一次失败」,不该被判为第 2 次。
2008
+ const outputs = ["不是 JSON", "[]", "又不是 JSON"];
2009
+ const output = outputs[attempt++] ?? "[]";
2010
+ return (async function* () {
2011
+ yield { type: "block-start", block: { type: "text" } };
2012
+ yield { type: "text-delta", delta: output };
2013
+ yield { type: "finish", kind: "ok" };
2014
+ })();
2015
+ }
2016
+ }
2017
+ );
2018
+ const handler = eventHandler(events);
2019
+ const session = {
2020
+ id: "s493-streak-reset",
2021
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
2022
+ events: [userMessage("第一窗", 1), { seq: 2, type: "turn/end" }]
2023
+ };
2024
+ await handler(session, { seq: 2, type: "turn/end" }); // 失败 #1
2025
+ await handler(session, { seq: 2, type: "turn/end" }); // 成功 → 清零
2026
+ session.events.push(userMessage("第二窗", 3), { seq: 4, type: "turn/end" });
2027
+ await handler(session, { seq: 4, type: "turn/end" }); // 失败 #1(不是 #2)
2028
+
2029
+ // 两条失败行都必须是「新一轮的第一次」:只查最后一条会让「第一条其实被
2030
+ // 记成 streak=2」这类回归漏网。
2031
+ const errorRows = service.listLlmAudits().filter((r) => r.status === "error");
2032
+ assert.equal(errorRows.length, 2);
2033
+ for (const row of errorRows) {
2034
+ assert.equal(row.metadata?.streak, 1, "成功必须清零,否则阈值会在跨窗处误触发");
2035
+ assert.equal(row.metadata?.skipped, undefined, "计数未达阈值,不得放弃窗口");
2036
+ }
2037
+ assert.equal(calls.length, 3);
2038
+ summarizer.dispose();
2039
+ store.close();
2040
+ });
@@ -39,6 +39,7 @@ const TOOL_NAMES = [
39
39
  "memory_archive",
40
40
  "memory_register_document",
41
41
  "memory_runtime",
42
+ "memory_organize",
42
43
  "set_scope_state"
43
44
  ];
44
45
 
@@ -117,13 +118,16 @@ function seedShapes(store) {
117
118
  return rows;
118
119
  }
119
120
 
121
+ // 清单比对必须在**暴露闸全开**的配置下做:#230 的 document 与 #231 的 organize 都是
122
+ // opt-in 工具,用默认配置跑会让「注册表里少一个工具」和「清单没更新」两件事互相
123
+ // 抵消——这条护栏就此静默失效,而漏登记正是 #195 要防的那类事故。
120
124
  test("工具矩阵与 TOOL_NAMES 清单一致(新增工具必须补进矩阵)", () => {
121
- const { byName } = setup();
125
+ const { byName } = setup({ documentMemoryEnabled: true, organizeEnabled: true });
122
126
  assert.deepEqual(Object.keys(byName).sort(), [...TOOL_NAMES].sort());
123
127
  });
124
128
 
125
- test("A 实跑:11 个工具每个可安全触达的分支,真实返回 ⊆ 声明的 output schema", async () => {
126
- const { store, service, byName } = setup({ documentMemoryEnabled: true });
129
+ test("A 实跑:12 个工具每个可安全触达的分支,真实返回 ⊆ 声明的 output schema", async () => {
130
+ const { store, service, byName } = setup({ documentMemoryEnabled: true, organizeEnabled: true });
127
131
  const shapes = seedShapes(store);
128
132
  const annotated = shapes[shapes.length - 1];
129
133
  const plain = shapes[0];
@@ -245,6 +249,43 @@ test("A 实跑:11 个工具每个可安全触达的分支,真实返回 ⊆
245
249
  rmSync(docDir, { recursive: true, force: true });
246
250
  }
247
251
 
252
+ // --- memory_organize(#231 第二批):dryRun 与 apply 两个分支各驱动一次 ---
253
+ // 两条分支的返回键集不同(dryRun 有 counts/items/created_at,apply 有
254
+ // saved/merged/archived/memory_ids/dry_run_id/degraded)。只跑一边会让另一边
255
+ // 「声明了却没被真实返回值验过」,正是本矩阵要抓的空洞。自建种子行而不是复用
256
+ // plain:register_document 那一支会归档它吸收的 evidence,两支不该互相改状态。
257
+ const organizeSeed = store.save({ type: "project", title: "organize-target", content: "库内既有的一行。", source: "test" });
258
+ const organizeReport = await drive(byName, "memory_organize", {
259
+ mode: "dryRun",
260
+ candidates: [
261
+ { type: "project", title: "organize-target", content: "同标题的另一段表述 → 应当判成 exact" },
262
+ { type: "project", title: "organize-brand-new", content: "库里没有的新条目" },
263
+ { type: "project", title: "", content: "缺标题 → 进 skipped,报告如实记 degraded" }
264
+ ]
265
+ });
266
+ assert.equal(organizeReport.status, "degraded", "有候选被拒就不得报 ok(红线 4 的如实标记)");
267
+ assert.equal(organizeReport.items.length, 2);
268
+ assert.equal(organizeReport.items[0].verdict, "exact");
269
+ assert.equal(organizeReport.items[0].match.id, organizeSeed.id);
270
+ assert.equal(organizeReport.skipped.length, 1);
271
+ const organized = await drive(byName, "memory_organize", {
272
+ mode: "apply",
273
+ run_id: organizeReport.run_id,
274
+ decisions: [
275
+ { action: "archive", id: organizeSeed.id },
276
+ { action: "save", index: 1 },
277
+ // index 99 不在这份报告的快照里 → 逐条跳过、应用合法子集(红线 4)。
278
+ // 故意不用「非法 action」走这条路:枚举在宿主参数校验层就挡掉了,能到达
279
+ // skipped 的只有索引与可见性两类,测试必须挑真实可达的那条。
280
+ { action: "save", index: 99 }
281
+ ]
282
+ });
283
+ assert.equal(organized.archived, 1);
284
+ assert.equal(organized.saved, 1);
285
+ assert.equal(organized.skipped.length, 1, "报告没收下过的索引逐条跳过 + 应用合法子集,绝不整单拒绝");
286
+ assert.equal(organized.degraded, true);
287
+ note("memory_organize");
288
+
248
289
  assert.deepEqual([...driven].sort(), [...TOOL_NAMES].sort(), "每个工具都必须至少被驱动一次(矩阵不允许空缺)");
249
290
  });
250
291
 
@@ -294,8 +335,10 @@ test("B' search / list / get 三个消费方共用同一份 item schema(#184
294
335
  }
295
336
  });
296
337
 
297
- test("C 结构:全部 11 个工具的 output schema 闭合、required ⊆ properties、每项带 type", () => {
298
- const { byName } = setup();
338
+ // 同清单比对那条:opt-in 工具在默认配置下不在注册表里,C 层按 TOOL_NAMES 取
339
+ // byName[name],所以必须用暴露闸全开的配置,否则新工具的 schema 结构从未被检查。
340
+ test("C 结构:全部 12 个工具的 output schema 闭合、required ⊆ properties、每项带 type", () => {
341
+ const { byName } = setup({ organizeEnabled: true });
299
342
  for (const name of TOOL_NAMES) {
300
343
  const schema = byName[name].output.schema;
301
344
  assert.ok(schema, `${name} 必须声明 output.schema`);
@@ -91,7 +91,12 @@ test("memory_search/archive descriptions carry tool-use discipline", () => {
91
91
  test("compiled schemas pass the enforced DSH subset (defineTool projection)", () => {
92
92
  const { registered } = setup();
93
93
  assert.equal(registered.length, 11);
94
- for (const tool of registered) {
94
+ // opt-in 暴露的工具在默认配置下不注册(#231 的 memory_organize),所以本用例还得
95
+ // 把暴露闸打开再走一遍——否则新工具的编译 schema 从来没被这条护栏看过,
96
+ // 「子集不支持的写法」要到运行时才炸。
97
+ const { registered: exposed } = setup(undefined, { organizeEnabled: true });
98
+ assert.equal(exposed.length, 12, "organizeEnabled 只多带一个工具,不许顺手改在场的别的东西");
99
+ for (const tool of [...registered, ...exposed]) {
95
100
  assertSupportedJsonSchema(tool.parameters);
96
101
  assertSupportedJsonSchema(tool.output.schema);
97
102
  const problems = [];
@@ -0,0 +1,127 @@
1
+ // 修复二:面板「Agent 眼中的画像」那张卡标榜的是「注入生效文本」,但它原本是前端
2
+ // 自己拼的(只有 profile + rules,既没有 [用户设置] 表头、没有「- 用户画像:」前缀,
3
+ // 也完全没有人格三行)。两块渲染代码各写各的,就一定会漂——所以这里要的是**同源**:
4
+ // 预览必须由注入器用的同一个渲染函数产出,而不是第二份实现。
5
+ // 本文件最关键的一条是第 1 条:拿同一个 settings、同一个 config、同一个会话身份,
6
+ // 让注入器与路由各渲染一次,断言逐字节相等。前端复刻模板的写法永远锁不住这条。
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { EventEmitter } from "node:events";
10
+ import { createStore } from "../src/store.js";
11
+ import { createService } from "../src/service.js";
12
+ import { createInjector } from "../src/inject.js";
13
+ import { createApi } from "../src/api.js";
14
+ import { createSettings } from "../src/settings.js";
15
+
16
+ class FakeRes extends EventEmitter {
17
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
18
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
19
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
20
+ }
21
+
22
+ function req(path, method = "GET") {
23
+ return { url: path, method, headers: {}, on() {}, emit() {} };
24
+ }
25
+
26
+ // 一份 settings/config 同时喂注入器与 API:两侧必须读同一个源,否则「同源」是假的。
27
+ function setup(config = {}) {
28
+ const store = createStore(":memory:");
29
+ const service = createService({ store, mirror: null, config });
30
+ const settings = createSettings(store.db);
31
+ const contexts = [];
32
+ const ctx = { systemPrompt: { context(def) { contexts.push(def); return () => {}; } } };
33
+ createInjector(ctx, service, settings, config);
34
+ const injectorText = (agentPreset) =>
35
+ contexts.find((c) => c.name === "user-settings").text(agentPreset ? { agent: { session: { header: { agentPreset } } } } : {});
36
+ const routes = [];
37
+ createApi(
38
+ { webServer: { register: (route) => { routes.push(route); return () => {}; } } },
39
+ service,
40
+ settings,
41
+ { add: () => {}, remove: () => {}, list: () => [] },
42
+ undefined,
43
+ undefined,
44
+ "",
45
+ config
46
+ );
47
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/persona");
48
+ const preview = async (agent) => {
49
+ const res = new FakeRes();
50
+ await route.handler(req(`/api/dsh-mneme/persona${agent ? `?agent=${agent}` : ""}`), res);
51
+ return JSON.parse(res.body);
52
+ };
53
+ return { store, settings, injectorText, preview };
54
+ }
55
+
56
+ function seed(settings) {
57
+ settings.setProfile("用户是规划背景");
58
+ settings.setRules(["不提及用户名"]);
59
+ settings.setPersona({ name: "Amarre", personality: "轻松幽默", replyStyle: "先结论后展开", enabled: true });
60
+ settings.setPersonaFor("zephyr", { name: "泽菲尔", personality: "冷危", enabled: true });
61
+ }
62
+
63
+ test("预览与注入器逐字节同源(同身份、同配置渲染两次必须一致)", async () => {
64
+ const { store, settings, injectorText, preview } = setup({ personaEnabled: true });
65
+ seed(settings);
66
+ for (const agent of ["zephyr", "unknown-bot", undefined]) {
67
+ const fromInjector = injectorText(agent);
68
+ const body = await preview(agent);
69
+ assert.equal(body.preview.text, fromInjector, `agent=${agent ?? "(无)"} 的预览必须等于注入器这一帧的输出`);
70
+ }
71
+ store.close();
72
+ });
73
+
74
+ test("预览含表头与画像前缀,且人格行在最前(原样复刻注入文本,不是前端拼的三条)", async () => {
75
+ const { store, settings, preview } = setup({ personaEnabled: true });
76
+ seed(settings);
77
+ const { preview: p } = await preview();
78
+ assert.ok(p.text.startsWith("[用户设置] 来自 dsh-mneme 的用户画像与规则:"), "表头缺失就不是「生效文本」");
79
+ assert.ok(p.text.includes("- 你的人格名字:Amarre"), "人格行必须在预览里——原来那段完全没有它");
80
+ assert.ok(p.text.includes("- 用户画像:用户是规划背景"), "画像前缀同理");
81
+ assert.ok(p.text.indexOf("你的人格名字") < p.text.indexOf("用户画像:"), "顺序即优先级:人格先出、画像与规则后出");
82
+ assert.equal(p.source, "default", "无 agent 参数 = 按「解析不到身份」寻址");
83
+ // agent 是面板区分两种 default 的唯一依据:解析不到身份(null)与「身份解析到了、
84
+ // 但没建卡/卡被关」(回显那个身份)。少了这条断言,面板那条回落文案就没有依据。
85
+ assert.equal(p.agent, null);
86
+ store.close();
87
+ });
88
+
89
+ test("身份解析到了却没有可用的卡:source=default 且回显该身份", async () => {
90
+ const { store, settings, preview } = setup({ personaEnabled: true });
91
+ seed(settings);
92
+ settings.setPersonaFor("ghost", { name: "无人格", enabled: false });
93
+ const { preview: p } = await preview("ghost");
94
+ assert.equal(p.source, "default", "默认口径下这张关掉的卡落回默认卡");
95
+ assert.equal(p.agent, "ghost", "但身份是解析到的——面板要靠它说「回落」而不是「解析不到」");
96
+ store.close();
97
+ });
98
+
99
+ test("预览按 agent 参数寻址,并说明用的是哪张卡", async () => {
100
+ const { store, settings, preview } = setup({ personaEnabled: true });
101
+ seed(settings);
102
+ const hit = await preview("zephyr");
103
+ assert.equal(hit.preview.source, "byAgent");
104
+ assert.equal(hit.preview.agent, "zephyr");
105
+ assert.ok(hit.preview.text.includes("你的人格名字:泽菲尔"));
106
+ store.close();
107
+ });
108
+
109
+ test("总闸关着时预览如实说「人格不会注入」,且不把人格行混进去", async () => {
110
+ const { store, settings, preview } = setup({ personaEnabled: false });
111
+ seed(settings);
112
+ const { preview: p } = await preview();
113
+ assert.equal(p.configGate, false, "面板要靠这个字段解释「卡开着为什么没生效」");
114
+ assert.ok(!p.text.includes("你的人格名字"), "预览里也不许出现实际不会注入的行");
115
+ assert.ok(p.text.includes("用户画像:"), "画像与规则照旧注入——闸只管人格");
116
+ store.close();
117
+ });
118
+
119
+ test("strict 寻址 + 身份未命中:source=none,人格行一段都不出现", async () => {
120
+ const { store, settings, preview } = setup({ personaEnabled: true, personaStrictAddressing: true });
121
+ seed(settings);
122
+ const { preview: p } = await preview("unknown-bot");
123
+ assert.equal(p.source, "none");
124
+ assert.ok(!p.text.includes("Amarre"), "外部身份在严格寻址下拿不到任何人格");
125
+ assert.ok(p.text.includes("用户画像:"), "画像与规则不受影响");
126
+ store.close();
127
+ });