@mrrisega/dsh-remote 0.6.10-beta.1 → 0.6.10-beta.11

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.
@@ -135,11 +135,17 @@ const TOKEN = process.env.DSH_BRIDGE_TOKEN || "";
135
135
  function machineUniqueId() {
136
136
  if (process.env.DSH_BRIDGE_MACHINE_FP) return String(process.env.DSH_BRIDGE_MACHINE_FP).slice(0, 64);
137
137
  if (process.platform === "darwin") {
138
- try {
139
- const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice", { encoding: "utf8", timeout: 5000 });
140
- const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
141
- if (m && m[1]) return m[1];
142
- } catch { /* 兜底 */ }
138
+ // ⚠️ 必须用**绝对路径**:由 launchd 拉起时 PATH 来自 plist(实测 /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin),
139
+ // **不含 /usr/sbin** —— 而 ioreg 恰好就在那里。用裸名会得到
140
+ // "/bin/sh: ioreg: command not found"(真机日志实测),于是这里静默返回 "",
141
+ // 指纹退化成 hostname 哈希:用户改一次主机名就被服务端当成新设备 —— 与 Windows 那条是同一个 bug。
142
+ for (const ioreg of ["/usr/sbin/ioreg", "ioreg"]) {
143
+ try {
144
+ const out = execSync(`${ioreg} -rd1 -c IOPlatformExpertDevice`, { encoding: "utf8", timeout: 5000 });
145
+ const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
146
+ if (m && m[1]) return m[1];
147
+ } catch { /* 换下一个候选 */ }
148
+ }
143
149
  } else if (process.platform === "linux") {
144
150
  for (const f of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
145
151
  try {
@@ -157,14 +163,20 @@ function machineUniqueId() {
157
163
  // · PowerShell 在受限语言模式 / AppLocker 的企业机器上可能被策略直接禁掉,
158
164
  // 而拿不到指纹时只能退回 hostname(用户一改主机名就被当成新设备)—— 正是要修的问题;
159
165
  // · 走 spawnSync 传数组参数,不经过 cmd.exe,因而没有引号/空格/中文路径的转义坑。
160
- try {
161
- const r = spawnSync("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
162
- { encoding: "utf8", timeout: 5000, windowsHide: true });
163
- const m = r && r.status === 0
164
- ? /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{16,})/.exec(String(r.stdout || ""))
165
- : null;
166
- if (m && m[1]) return m[1];
167
- } catch { /* 兜底 */ }
166
+ // 同样不赌 PATH:优先 System32 下的绝对路径,取不到再退回裸名(macOS 那条就是这么栽的)。
167
+ const regCandidates = [];
168
+ if (process.env.SystemRoot) regCandidates.push(`${process.env.SystemRoot}\\System32\\reg.exe`);
169
+ regCandidates.push("reg");
170
+ for (const reg of regCandidates) {
171
+ try {
172
+ const r = spawnSync(reg, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
173
+ { encoding: "utf8", timeout: 5000, windowsHide: true });
174
+ const m = r && r.status === 0
175
+ ? /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{16,})/.exec(String(r.stdout || ""))
176
+ : null;
177
+ if (m && m[1]) return m[1];
178
+ } catch { /* 换下一个候选 */ }
179
+ }
168
180
  }
169
181
  return ""; // 读不到(如容器/受限环境) → 回退宿主名指纹
170
182
  }
@@ -1212,6 +1224,119 @@ function tagWeChat(m) {
1212
1224
  return s.startsWith("[") ? s : `[wechat] ${s}`;
1213
1225
  }
1214
1226
 
1227
+ /**
1228
+ * 微信通道的档位来源(免费/付费分层)。
1229
+ *
1230
+ * 取值 = 账号的**生效套餐**,服务端只有三种:free / pro / pro_max
1231
+ * (见企业端 auth.js 的 PLAN_PRIORITY;试用期服务端已折算成 pro,这里不必自己判断)。
1232
+ *
1233
+ * ★ 返回**两种契约**(见 docs/entitlements-contract.md §5/§6.1):
1234
+ * · 服务端 `/api/me` 带了 `entitlements`(后台可配置的权益包)→ 返回**对象**
1235
+ * `{plan, caps, limits, rev}`,runtime 以它为准判定能力 —— 这样「后台改配置 = 用户侧实时生效」
1236
+ * 才成立(无需发版)。caps/limits **原样透传**:权限判定以服务端为唯一真相源,
1237
+ * 客户端任何"二次加工/本地过滤"都会让展示与判定分叉,也会让后台配置失效(红线 §8.1/§8.2)。
1238
+ * · 服务端**没带** `entitlements`(灰度期的旧服务端)→ 保持今天的行为、返回**字符串**档位:
1239
+ * runtime 有上次的包就继续用它,一份包都没有才按内置表解析。新旧服务端都要能跑,
1240
+ * 所以这里不能只认新契约。
1241
+ *
1242
+ * ⚠️ 取不到时返回**空串**,而不是 "free":空串让 runtime 沿用上次成功取到的档位。
1243
+ * 一次网络抖动就把付过钱的用户降级成免费、还提示他去升级,比多给几分钟权限糟糕得多。
1244
+ * ⚠️ 自建部署直接给最高档:用户跑的是自己的服务器,不存在"会员"这回事,
1245
+ * 不能拿 SaaS 的分层去卡自建用户。
1246
+ */
1247
+ /**
1248
+ * 档位查询专用的 token 缓存(理由见 resolveWechatTier 里的注释)。
1249
+ * TTL 取 5 分钟:远小于 JWT 有效期,又足以把「每 10 分钟一次定时校准 + 被拒即重查」
1250
+ * 全部挡在网络之外 —— 不缓存的话,免费用户连着发几条消息就会触发一串 device-login。
1251
+ */
1252
+ const WECHAT_TIER_TOKEN_TTL_MS = 5 * 60_000;
1253
+ let WECHAT_TIER_TOKEN = { value: "", at: 0 };
1254
+ /**
1255
+ * 最近一次 `/api/me` 的账号快照(查档位时顺手记下来)。
1256
+ *
1257
+ * 为什么需要:微信通道要做"会员临近到期"提醒,而它**不该再单独打一次** `/api/me` ——
1258
+ * 档位每 10 分钟本来就会查一次,顺手记住即可。查失败时**保持上次的值**(与档位同一策略:
1259
+ * 一次网络抖动不该让提醒凭空消失或变成乱猜)。
1260
+ */
1261
+ let WECHAT_ACCOUNT_SNAPSHOT = null;
1262
+ /** 供微信通道取账号快照(到期提醒用)。没查过就返回 null → 上层什么都不发。 */
1263
+ async function wechatAccountInfo() {
1264
+ return WECHAT_ACCOUNT_SNAPSHOT;
1265
+ }
1266
+
1267
+ async function resolveWechatTier() {
1268
+ if (process.env.DSH_BRIDGE_LOCAL_KEY) return "pro_max";
1269
+ try {
1270
+ // ⚠️ `resolveToken()` **不带缓存**:账号模式下每调一次就重新 device-login 一次
1271
+ // (见它的第一行,只有 DSH_BRIDGE_TOKEN / 自建模式才短路)。
1272
+ // 而档位会被反复查询:每 10 分钟一次定时校准 + 免费用户**每次被拒**都重查(最快 5 秒一次)。
1273
+ // 拿它直接查 = 用户连着发几条消息就把自己打到登录限流里 —— 而 /api/device-login
1274
+ // 是**按出口 IP 限流**的(15 分钟 5 次),连累的不只是他自己。
1275
+ // 所以这里自己缓存 token。
1276
+ const now = Date.now();
1277
+ if (!WECHAT_TIER_TOKEN.value || now - WECHAT_TIER_TOKEN.at > WECHAT_TIER_TOKEN_TTL_MS) {
1278
+ const t = await resolveToken();
1279
+ if (!t) return "";
1280
+ WECHAT_TIER_TOKEN = { value: t, at: now };
1281
+ }
1282
+ const r = await fetch(`${API_BASE}/api/me`, {
1283
+ headers: { authorization: `Bearer ${WECHAT_TIER_TOKEN.value}` },
1284
+ signal: AbortSignal.timeout(6000)
1285
+ });
1286
+ // token 过期/被吊销 → 丢掉缓存,下一次查询重新登录(而不是一直拿坏 token 打)
1287
+ if (r.status === 401 || r.status === 403) {
1288
+ WECHAT_TIER_TOKEN = { value: "", at: 0 };
1289
+ return "";
1290
+ }
1291
+ if (!r.ok) return "";
1292
+ const j = await r.json().catch(() => null);
1293
+ const u = (j && j.user) || null;
1294
+ // 顺手记账号快照(到期提醒用)。⚠️ 只在**真拿到了 user** 时覆盖 —— 否则保持上次。
1295
+ if (u) {
1296
+ WECHAT_ACCOUNT_SNAPSHOT = {
1297
+ plan: String(u.plan || ""),
1298
+ plan_ends_at: u.plan_ends_at ?? null,
1299
+ trial_expires_at: u.trial_expires_at ?? null
1300
+ };
1301
+ }
1302
+ // ★ 服务端权益包(契约 §5):有它就返回对象,runtime 以服务端 caps/limits 为准。
1303
+ //
1304
+ // 🔴 判据是「`caps` 是**数组**」,**空数组也算给了**(2026-09-23 修):
1305
+ // 后台「明确清空」某档(契约 §4.1 的 `EMPTY_CAPS_VALUE`,下发即 `caps: []`)表达的是
1306
+ // **这个档位什么能力都没有**。如果这里把空数组当成"没给",runtime 就会沿用上次那份包
1307
+ // (一份都没有时回退**内置表**)—— 于是"我清空了 pro"变成"pro 拿回内置的一整套付费能力",
1308
+ // 比后台意图**更多**权限。那是 fail-open,方向正好反了(契约 §8 红线 2)。
1309
+ // 所以:数组(含空) = 权威;不是数组/字段缺失 = 这次没拿到 → 退回字符串契约沿用上次。
1310
+ const ent = j && j.entitlements;
1311
+ if (ent && typeof ent === "object" && Array.isArray(ent.caps)) {
1312
+ return {
1313
+ // plan 也原样取服务端的值;只有权益包里缺 plan 时才退回 user.plan(同一份真相的旧字段)。
1314
+ plan: String(ent.plan || (u && u.plan) || ""),
1315
+ caps: ent.caps,
1316
+ // ⚠️ limits 缺失时必须给 null,**不能补 `{}`**:runtime 里 `{}` 是"有效的一张限制表",
1317
+ // 于是 messages_per_month 读成 0 = 不限 → 免费用户当场变成无限额度(等于放权)。
1318
+ // null 才会让 runtime 沿用上次的值 / 回退内置表 —— 缺字段绝不等于放开(契约 §8.2 fail-closed)。
1319
+ limits: ent.limits && typeof ent.limits === "object" ? ent.limits : null,
1320
+ rev: String(ent.rev || "")
1321
+ };
1322
+ }
1323
+ // 没有权益包(旧服务端 / 灰度期)→ 保持旧契约:只给档位字符串。
1324
+ // runtime 收到字符串后:有上次的包就继续用它,一份包都没有才按内置表解析(冷启动兜底)。
1325
+ return String((u && u.plan) || "");
1326
+ } catch {
1327
+ return "";
1328
+ }
1329
+ }
1330
+
1331
+ /**
1332
+ * App 入口(免费用户越界时给的转化链接)。
1333
+ * 由账号 API 地址推导:去掉 `/relay-api` 再加 `/app/` —— 与插件半的 DEFAULT_APP_URL 同一口径
1334
+ * (https://host:port/relay-api → https://host:port/app/)。
1335
+ */
1336
+ function wechatAppUrl() {
1337
+ return `${API_BASE.replace(/\/relay-api\/?$/, "")}/app/`;
1338
+ }
1339
+
1215
1340
  let wechatRuntime = null;
1216
1341
  function startWeChat() {
1217
1342
  if (WECHAT_DISABLED) {
@@ -1226,6 +1351,11 @@ function startWeChat() {
1226
1351
  upstream: UPSTREAM,
1227
1352
  cookieOf: harnessCookieOf,
1228
1353
  secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
1354
+ // 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
1355
+ tierProvider: resolveWechatTier,
1356
+ // 到期提醒的数据来源(复用上面那次 /api/me,不额外打网络)
1357
+ accountInfo: wechatAccountInfo,
1358
+ appUrl: wechatAppUrl(),
1229
1359
  // 模块自己的消息已带 `[wechat]` / `[wechat/events]` 前缀,这里**不能再加一次**
1230
1360
  // (真机日志里出现过 `[wechat] [wechat] 控制面已就绪`)。没前缀的兜底补一个。
1231
1361
  logger: {
@@ -1268,6 +1398,10 @@ async function runTunnel() {
1268
1398
  console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
1269
1399
  process.exit(1);
1270
1400
  }
1401
+ // 把刚拿到的 JWT 喂给档位查询的缓存:否则 startWeChat 里的档位校准会**再登录一次**
1402
+ // (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
1403
+ // 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
1404
+ WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
1271
1405
  // 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)
1272
1406
  persistBridgeState({ device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
1273
1407
  await initE2ee(token);
@@ -42,11 +42,13 @@ function loadMachineUniqueId({
42
42
  assert.ok(!/稳定指纹/.test(src), "切片不应越过 machineUniqueId 的边界");
43
43
 
44
44
  const calls = [];
45
+ const execCalls = [];
45
46
  const fakeSpawnSync = (cmd, args, opts) => {
46
47
  calls.push({ cmd, args, opts });
47
48
  if (regThrows) throw new Error("spawn EPERM");
48
49
  return reg;
49
50
  };
51
+ const fakeExecSync = (cmd) => { execCalls.push(String(cmd)); return ioreg; };
50
52
  const fakeFs = {
51
53
  readFileSync: (f) => {
52
54
  if (f in machineIdFiles) return machineIdFiles[f];
@@ -55,8 +57,8 @@ function loadMachineUniqueId({
55
57
  };
56
58
  const make = new Function("process", "execSync", "spawnSync", "fs", "os",
57
59
  `${src}\nreturn machineUniqueId;`);
58
- const fn = make({ platform, env }, () => ioreg, fakeSpawnSync, fakeFs, {});
59
- return { fn, calls };
60
+ const fn = make({ platform, env }, fakeExecSync, fakeSpawnSync, fakeFs, {});
61
+ return { fn, calls, execCalls };
60
62
  }
61
63
 
62
64
  test("win32:机器指纹取注册表 MachineGuid(改主机名不再被当成新设备)", () => {
@@ -100,6 +102,9 @@ test("机器指纹:darwin/linux 两条老分支没有被改坏", () => {
100
102
  ioreg: ' "IOPlatformUUID" = "AAAA-BBBB-CCCC"\n',
101
103
  });
102
104
  assert.equal(darwin.fn(), "AAAA-BBBB-CCCC", "darwin 仍读 ioreg 的 IOPlatformUUID");
105
+ assert.match(darwin.execCalls[0], /^\/usr\/sbin\/ioreg /,
106
+ "★必须用绝对路径调 ioreg:launchd 的 plist PATH 不含 /usr/sbin,用裸名会 command not found,",
107
+ );
103
108
 
104
109
  const linux = loadMachineUniqueId({
105
110
  platform: "linux",
@@ -108,6 +113,29 @@ test("机器指纹:darwin/linux 两条老分支没有被改坏", () => {
108
113
  assert.equal(linux.fn(), "0123456789abcdef", "linux 仍读 /etc/machine-id(并去掉换行)");
109
114
  });
110
115
 
116
+ test("★回归:子进程一律用绝对路径优先,不赌 PATH(launchd 的 PATH 不含 /usr/sbin 的真实事故)", () => {
117
+ // 事故(2026-09-22 真机日志):bridge 由 launchd 拉起,plist 给的 PATH 是
118
+ // /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin —— 没有 /usr/sbin,而 ioreg 在那里。
119
+ // 日志里出现 `/bin/sh: ioreg: command not found`,指纹**静默**退化成 hostname 哈希:
120
+ // 用户改一次主机名就被服务端当成新设备。这类"PATH 假设"必须用绝对路径堵死。
121
+ const mac = loadMachineUniqueId({ platform: "darwin", ioreg: 'x "IOPlatformUUID" = "U1"\n' });
122
+ mac.fn();
123
+ assert.match(mac.execCalls[0], /^\/usr\/sbin\/ioreg /, "darwin:首个候选必须是 /usr/sbin/ioreg");
124
+
125
+ // 绝对路径取不到时仍要退回裸名(别把别的机型的 macOS 弄死)
126
+ const macFallback = loadMachineUniqueId({ platform: "darwin", ioreg: "" });
127
+ assert.equal(macFallback.fn(), "", "两次都拿不到 → 空串(不是抛)");
128
+ assert.equal(macFallback.execCalls.length, 2, "应依次尝试绝对路径与裸名");
129
+ assert.match(macFallback.execCalls[1], /^ioreg /, "第二个候选是裸名");
130
+
131
+ // Windows 同理:SystemRoot 可用时优先 System32 的绝对路径
132
+ const win = loadMachineUniqueId({ platform: "win32", env: { SystemRoot: "C:\\Windows" } });
133
+ win.fn();
134
+ assert.equal(win.calls[0].cmd, "C:\\Windows\\System32\\reg.exe", "win32:应优先 System32 下的绝对路径");
135
+ assert.deepEqual(win.calls[0].args, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
136
+ assert.equal(win.calls[0].opts.windowsHide, true, "绝对路径候选同样必须 windowsHide");
137
+ });
138
+
111
139
  test("机器指纹:拿不到机器唯一值时才退回宿主名哈希(这正是要修的退化路径)", () => {
112
140
  // 断言 machineFingerprint 的兜底表达式仍然存在 —— 它是"改主机名 = 新设备"的直接来源,
113
141
  // 修好 win32 分支后这条兜底只应在容器等极端环境生效。
@@ -29,6 +29,10 @@ import test from "node:test";
29
29
  import {
30
30
  BindSession,
31
31
  COMMANDS,
32
+ COMPLETION_SUMMARY_MAX,
33
+ markdownToWechatText,
34
+ COMPLETION_TEXT_MAX,
35
+ SESSION_SUMMARY_MAX,
32
36
  DEFAULT_ILINK_BASE_URL,
33
37
  EventRegistry,
34
38
  HELP_TEXT,
@@ -1763,9 +1767,68 @@ test("完成模板:头一行按 reason 区分,completed 不许读成失败", ()
1763
1767
  assert.match(headers.get("error"), /出错/);
1764
1768
  });
1765
1769
 
1770
+ test("★ 微信不渲染 Markdown:模型产出的表格/加粗/链接必须先转成纯文本", () => {
1771
+ // 业主原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」。
1772
+ // 根因:微信**不渲染** Markdown(腾讯自己的插件在出站路径主动剥离),所以模型的表格会以
1773
+ // 字面竖线、加粗会以字面星号出现在气泡里。这儿锁住"发送前必须已转换"。
1774
+ const md = [
1775
+ "## 修复结果",
1776
+ "",
1777
+ "| 项目 | 状态 |",
1778
+ "|---|---|",
1779
+ "| 黑窗 | 已修 |",
1780
+ "| 指纹 | 已修 |",
1781
+ "",
1782
+ "**关键**:见 [提交](https://example.com/c/1)。",
1783
+ "",
1784
+ "- 测试通过",
1785
+ "- `kill -9` 后自愈"
1786
+ ].join("\n");
1787
+ const out = markdownToWechatText(md);
1788
+
1789
+ assert.doesNotMatch(out, /\*\*/u, "加粗标记必须剥掉(留着就是星号噪声)");
1790
+ assert.doesNotMatch(out, /\|/u, "★表格必须转成列表 —— 字面竖线在微信里就是一团乱码");
1791
+ assert.match(out, /· 项目:黑窗 | 状态:已修/u, "表格行要变成「列名:值 | 列名:值」");
1792
+ assert.match(out, /【修复结果】/u, "标题要变成【】独占一行");
1793
+ assert.ok(out.includes("https://example.com/c/1"), "★链接必须留下**裸 URL**,否则微信不会变成可点链接");
1794
+ assert.doesNotMatch(out, /\]\(/u, "不得残留 markdown 链接语法");
1795
+ assert.match(out, /^· 测试通过$/mu, "无序列表统一成 ·");
1796
+ assert.match(out, /kill -9 后自愈/u, "行内代码只去反引号,内容保留");
1797
+ assert.doesNotMatch(out, /`/u, "反引号必须剥掉");
1798
+
1799
+ // 不能把乘号吃掉:`2 * 3 * 4` 里的单星号不是强调
1800
+ assert.match(markdownToWechatText("结果 2 * 3 * 4 正确"), /2 \* 3 \* 4/,
1801
+ "单个 * 只在看起来像强调时才剥,不能误伤乘号");
1802
+
1803
+ // 代码围栏:去掉围栏行,内容原样保留
1804
+ const fenced = markdownToWechatText("```js\nconst a = 1;\n```");
1805
+ assert.doesNotMatch(fenced, /```/u, "围栏行要去掉");
1806
+ assert.match(fenced, /const a = 1;/u, "围栏内的内容要保留");
1807
+
1808
+ // 空输入/纯空白不炸
1809
+ assert.equal(markdownToWechatText(""), "");
1810
+ assert.equal(markdownToWechatText(null), "");
1811
+ assert.equal(markdownToWechatText(" \n\n "), "");
1812
+ });
1813
+
1814
+ test("★★ 截断只压正文:头部与「下一步」必须活着(旧实现会把用户唯一能照做的那句话切掉)", () => {
1815
+ // 旧实现:整条 join 后盲切尾部。而「回复 N … / 下一步」恰好拼在最后 →
1816
+ // 一条超长消息会把**用户唯一能照做的指引**切掉(真机投诉过的形态)。
1817
+ const huge = "长".repeat(COMPLETION_SUMMARY_MAX * 5);
1818
+ const out = formatCompletion({ title: "超长任务", reason: "completed", summary: huge });
1819
+ assert.match(out.text, /【任务正常完成】/u, "头部必须活着");
1820
+ assert.match(out.text, /会话:超长任务/u, "会话名必须活着");
1821
+ assert.match(out.text, /—— 下一步 ——/u, "★「下一步」分区必须活着");
1822
+ assert.match(out.text, /直接回复一句话/u, "★那句可照做的指引必须活着");
1823
+ assert.ok(out.text.length <= COMPLETION_TEXT_MAX, `整条要限长,实际 ${out.text.length}`);
1824
+
1825
+ // 正文自身超长仍要**显式**标注(不静默丢)
1826
+ assert.match(out.text, /已截断/u, "正文被压时要显式标注");
1827
+ });
1828
+
1766
1829
  test("完成模板:会话名在/不在都不能打印 undefined", () => {
1767
1830
  const named = formatCompletion({ title: "修登录跳转", reason: "completed", summary: "修好了。" });
1768
- assert.match(named.text, /会话:修登录跳转/, "会话名是用户最想看到的一行");
1831
+ assert.match(named.text, /会话:修登录跳转/, "会话名是用户最想看到的一行");
1769
1832
  assert.equal(named.title, `任务${stopReasonText("completed")}`, "title 是展示标题(和 formatNotification 同义)");
1770
1833
 
1771
1834
  for (const t of ["", " ", undefined, null]) {
@@ -1779,15 +1842,31 @@ test("完成模板:会话名在/不在都不能打印 undefined", () => {
1779
1842
  assert.doesNotMatch(byId.text, /session-abcdef1234567890/, "别把整个 id 印给用户看");
1780
1843
  });
1781
1844
 
1782
- test("完成模板:结论在/不在/超长截断;hanging 说的是'取不到'", () => {
1845
+ test("完成模板:结论要**尽量完整**(未超上限不截断),超长才显式标注;hanging 说的是'取不到'", () => {
1783
1846
  const withSummary = formatCompletion({ title: "T", reason: "completed", summary: "结论正文" });
1784
- assert.match(withSummary.text, /结论:结论正文/);
1785
-
1786
- const long = "长".repeat(1200);
1787
- const truncated = formatCompletion({ title: "T", reason: "completed", summary: long });
1847
+ // 结论**独立成段**(不再挤在 "结论:" 前缀后面) —— 压成一行正是"密密麻麻"的来源
1848
+ assert.match(withSummary.text, /—— 结论 ——\n结论正文/, "结论要单独成段、原样展示");
1849
+
1850
+ // ★ 业主口径(2026-09-22):「结论被大量截断了,加长一些,更完整地展示 —— 这可能是用户关注的内容」
1851
+ // 旧上限 400 连一条正常的修复总结(实测 190+ 字)都快保不住。
1852
+ const fits = "长".repeat(1200);
1853
+ const kept = formatCompletion({ title: "T", reason: "completed", summary: fits });
1854
+ assert.ok(kept.text.includes(fits),
1855
+ `★未超上限的结论必须**整段**展示(旧上限 400 会砍掉它),实际整条长度 ${kept.text.length}`);
1856
+ assert.doesNotMatch(kept.text, /已截断/, "没超上限就不该出现截断标注");
1857
+
1858
+ // 远超上限时仍必须**显式**标注截断(不静默丢)
1859
+ const huge = "长".repeat(COMPLETION_SUMMARY_MAX * 3);
1860
+ const truncated = formatCompletion({ title: "T", reason: "completed", summary: huge });
1788
1861
  assert.match(truncated.text, /已截断/, "超长必须有**显式**截断标注");
1789
- assert.ok(truncated.text.length <= 900, `整条要限在微信可读长度内,实际 ${truncated.text.length}`);
1790
- assert.equal(truncated.text.includes(long), false, "超长结论不得整段带出");
1862
+ assert.ok(truncated.text.length <= COMPLETION_TEXT_MAX,
1863
+ `整条要限在微信可读长度内,实际 ${truncated.text.length}`);
1864
+ assert.equal(truncated.text.includes(huge), false, "超长结论不得整段带出");
1865
+
1866
+ // ★ 两道闸的关系:取值侧(会话历史)不能比展示侧(通知模板)更小,
1867
+ // 否则结论会在拼接**之前**就被砍掉 —— 只盯 formatter 会找不到真正的截断点(历史踩过)
1868
+ assert.ok(SESSION_SUMMARY_MAX >= COMPLETION_SUMMARY_MAX,
1869
+ `取值上限(${SESSION_SUMMARY_MAX})必须 ≥ 展示上限(${COMPLETION_SUMMARY_MAX}),否则在更早处就被砍掉`);
1791
1870
 
1792
1871
  const noSummary = formatCompletion({ title: "T", reason: "completed" });
1793
1872
  assert.match(noSummary.text, /没有产出结论/);