@mrrisega/dsh-remote 0.7.12-beta.6 → 0.8.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.
@@ -11,7 +11,10 @@ import test from "node:test";
11
11
 
12
12
  import {
13
13
  CHANNEL_CAPS,
14
+ SUMMARY_MAX_CHARS,
14
15
  TRUNCATED_NOTE,
16
+ buildTurnNotification,
17
+ extractTurnSummary,
15
18
  convertMarkdownTable,
16
19
  displayWidth,
17
20
  formatForChannel,
@@ -227,3 +230,60 @@ test("wechat 分支:走现有特殊格式(本模块只做 plain 兜底、不
227
230
  assert.ok(!unknown.includes("**"));
228
231
  assert.equal(unknown, "标题\n\n粗");
229
232
  });
233
+
234
+ // ─────────────────────────── 任务总结(turn_done/turn_error 通知带正文)───────────────────────────
235
+
236
+ test("★ extractTurnSummary:订阅器正文优先 → 事件字段降级 → 取不到为空(不报错)", async () => {
237
+ // ① 订阅器有正文
238
+ const sub = { lastAssistantText: async () => ({ ok: true, text: "订阅器里的总结" }) };
239
+ const a = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x" }, subscriber: sub, sessionId: "session-x" });
240
+ assert.deepEqual(a, { text: "订阅器里的总结", source: "assistant" });
241
+ // ② 订阅器取不到 → 事件字段(summary/message/detail/text)
242
+ const b = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x", message: "事件里的错误摘要" }, subscriber: { lastAssistantText: async () => ({ ok: false, code: "page-failed" }) }, sessionId: "session-x" });
243
+ assert.deepEqual(b, { text: "事件里的错误摘要", source: "node" });
244
+ // ③ 都没有 → 空(调用方降级仅标题)
245
+ const c = await extractTurnSummary({ node: { kind: "turn-end", sessionId: "session-x" }, subscriber: null });
246
+ assert.equal(c.text, "");
247
+ assert.equal(c.source, "none");
248
+ // ④ 订阅器抛错也不崩,继续降级
249
+ const d = await extractTurnSummary({ node: { kind: "turn-end", summary: "兜底" }, subscriber: { lastAssistantText: async () => { throw new Error("boom"); } }, sessionId: "s" });
250
+ assert.deepEqual(d, { text: "兜底", source: "node" });
251
+ // ⑤ 重试:ok 但空 → 第二次拿到(turn/end 瞬间正文可能还没落库)
252
+ let n = 0;
253
+ const retried = await extractTurnSummary({
254
+ node: { kind: "turn-end", sessionId: "s" },
255
+ subscriber: { lastAssistantText: async () => { n += 1; return n === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后的总结" }; } },
256
+ sessionId: "s",
257
+ retryDelays: [0, 5]
258
+ });
259
+ assert.equal(retried.text, "重试后的总结");
260
+ assert.equal(n, 2);
261
+ });
262
+
263
+ test("★ buildTurnNotification:标题行 + 正文(1500 字上限/通道格式化/缺正文降级);审批类返回 null", () => {
264
+ // completed:✅ 任务完成|标题 + 正文
265
+ const done = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: "改了三个文件", sessionTitle: "修端口" });
266
+ assert.equal(done, "✅ 任务完成|修端口\n\n改了三个文件");
267
+ // error / blocked / aborted
268
+ assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "error" }, channel: "wecom", summary: "报错摘要" }), /^❌ 出错了\n\n报错摘要$/);
269
+ assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "blocked" }, channel: "wecom", summary: "被拦原因" }), /^⛔ 被拦下了\n\n被拦原因$/);
270
+ assert.match(buildTurnNotification({ node: { kind: "turn-end", reason: "aborted" }, channel: "wecom" }), /^⏹ 已停止$/);
271
+ // session-error 也用 ❌ 出错了
272
+ assert.match(buildTurnNotification({ node: { kind: "session-error", message: "x" }, channel: "wecom" }), /^❌ 出错了/);
273
+ // 缺正文 → 仅标题(不留多余空段)
274
+ assert.equal(buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: "" }), "✅ 任务完成");
275
+ // 审批类不混入总结(返回 null,仍走卡片)
276
+ assert.equal(buildTurnNotification({ node: { kind: "approval-request", toolName: "Bash" }, channel: "wecom", summary: "不该出现" }), null);
277
+ // 表格按通道转换(企微不支持表格 → 可读列表)
278
+ const table = "| 文件 | 改动 |\n|---|---|\n| a.mjs | 修端口 |";
279
+ const withTable = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "wecom", summary: table });
280
+ assert.ok(!/\|\s*-{2,}/.test(withTable), "★ 通知里不得有裸表格分隔行");
281
+ assert.match(withTable, /• 文件:a\.mjs · 改动:修端口/);
282
+ // 1500 字上限(长正文截断 + 注明)
283
+ const long = "开头" + "长".repeat(3000);
284
+ const longNotice = buildTurnNotification({ node: { kind: "turn-end", reason: "completed" }, channel: "feishu", summary: long });
285
+ const body = longNotice.split("\n\n").slice(1).join("\n\n");
286
+ assert.ok(body.endsWith(TRUNCATED_NOTE), "超长正文要带截断提示");
287
+ assert.ok(Array.from(body).length <= SUMMARY_MAX_CHARS + Array.from(TRUNCATED_NOTE).length + 2, "正文不超过 1500 字(+提示)");
288
+ assert.ok(!body.includes("长".repeat(2000)), "明显截断");
289
+ });
@@ -690,62 +690,56 @@ async function setupRelayBot(over = {}) {
690
690
  return { server, sub, bot, lines, sends, inbound, turnEnd, close: async () => { await bot.stop(); await server.close(); } };
691
691
  }
692
692
 
693
- test("★ 回答回传:入站后同会话 turn_done 发**回答正文**(不再发 ✅标题);其它会话/已消费保持标题通知;取不到正文降级标题", async () => {
694
- const rt = await setupRelayBot({ assistantText: { ok: true, text: "这是 agent 的最终回答" } });
693
+ test("★ 任务通知带总结(企微):turn_done → 一条「✅ 任务完成|标题 + 正文」;同 turn 不重发;缺正文降级标题;正文不进日志", async () => {
694
+ const rt = await setupRelayBot({ assistantText: { ok: true, text: "这是 agent 的最终总结" } });
695
695
  try {
696
696
  rt.bot.start();
697
697
  await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
698
698
  await rt.inbound("relay-1", "你好");
699
-
700
- // ① 入站后同会话 turn/end → 只发正文,不发标题
701
699
  rt.turnEnd({ turn: 1, seq: 11 });
702
- const first = await waitFor(() => rt.sends()[0], { label: "回答回传" });
703
- assert.equal(first.body.chatid, "user-a", "回答必须发回最近入站的会话(boundChatId)");
700
+ const first = await waitFor(() => rt.sends()[0], { label: "任务通知" });
701
+ assert.equal(first.body.chatid, "user-a");
704
702
  assert.equal(first.body.msgtype, "markdown");
705
- assert.equal(first.body.markdown.content, "这是 agent 的最终回答", "回答回传 = 正文本身(不带 ✅ 标题)");
706
- assert.equal(rt.bot.status().answers, 1);
707
- assert.deepEqual(rt.sub.lastAssistantCalls, [{ sessionId: "session-abc" }], "应通过 lastAssistantText 取正文");
703
+ const content = first.body.markdown.content;
704
+ assert.match(content, /^✅ 任务完成/, "★ 通知必须带「任务完成」标题行");
705
+ assert.match(content, /\n\n这是 agent 的最终总结$/, "★ 同一条消息里带总结正文(不是另发一条)");
706
+ assert.deepEqual(rt.sub.lastAssistantCalls[0], { sessionId: "session-abc" }, "正文通过 lastAssistantText 取");
707
+ await sleep(120);
708
+ assert.equal(rt.sends().length, 1, "★ 同一次 turn 只发一条(不得标题一条 + 正文一条)");
709
+
710
+ // 同 turn 重复事件 → 去重,不重发
711
+ rt.turnEnd({ turn: 1, seq: 11 });
708
712
  await sleep(120);
709
- assert.equal(rt.sends().length, 1, "同一次对话不得再补一条「✅ 任务完成」");
713
+ assert.equal(rt.sends().length, 1, "★ 同 turn 重复事件去重");
710
714
 
711
- // ② 其它会话的 turn/end → 仍是标题通知
715
+ // 其它会话的 turn/end 也带总结(订阅器能给到该会话正文)
712
716
  rt.sub.emit("event", { kind: NODE_KINDS.TURN_END, sessionId: "session-other", reason: "completed", turn: 1, seq: 12, at: Date.now() });
713
- const other = await waitFor(() => rt.sends().find((f) => /任务完成/.test(f.body.markdown.content)), { label: "其它会话标题通知" });
717
+ const other = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("这是 agent 的最终总结") && f !== first), { label: "其它会话通知" });
714
718
  assert.match(other.body.markdown.content, /^✅ 任务完成/);
715
719
 
716
- // ③ 同会话但已消费过一次 → 保持标题通知(不会重复回传正文)
717
- rt.turnEnd({ turn: 2, seq: 13 });
718
- const again = await waitFor(() => rt.sends().filter((f) => /任务完成/.test(f.body.markdown.content)).length >= 2, { label: "消费一次后的标题通知" });
719
- assert.ok(again, "同一次入站只回传一次正文,之后回落到标题通知");
720
-
721
- // ④ 取不到正文 → 降级标题通知(不丢消息、也不假装有正文)
720
+ // 取不到正文 → 降级仅标题(不丢消息、不报错)
722
721
  await rt.inbound("relay-2", "再问一句");
723
722
  rt.sub.assistantReply = { ok: false, code: "page-failed", text: "" };
724
723
  rt.turnEnd({ turn: 3, seq: 14 });
725
- await waitFor(() => rt.lines.some((l) => /降级为标题通知/.test(l)), { label: "降级日志" });
726
- const fallback = rt.sends().filter((f) => /任务完成/.test(f.body.markdown.content)).pop();
727
- assert.match(fallback.body.markdown.content, /^✅ 任务完成/, "取不到正文时退回标题通知");
728
-
729
- // ⑤ 重连快照补报的旧 turn(fromSnapshot)不得回传正文
730
- await rt.inbound("relay-3", "第三句");
731
- rt.sub.assistantReply = { ok: true, text: "来自快照的旧回答" };
732
- const beforeSnapshot = rt.sends().length;
733
- rt.turnEnd({ turn: 4, seq: 15, fromSnapshot: true });
734
- await sleep(150);
735
- assert.equal(rt.sends().length, beforeSnapshot + 1, "fromSnapshot 只走标题通知(一条),不触发正文回传");
736
- assert.ok(!rt.sends().some((f) => f.body.markdown.content === "来自快照的旧回答"));
724
+ await waitFor(() => rt.sends().length >= 3, { label: "降级通知" });
725
+ const fallback = rt.sends()[rt.sends().length - 1];
726
+ assert.match(fallback.body.markdown.content, /^✅ 任务完成/, "★ 取不到正文时仅标题");
727
+ assert.ok(!fallback.body.markdown.content.includes("\n\n"), "无正文时不要多余空段");
728
+ // 正文绝不进日志(只打长度)
729
+ const allLogs = rt.lines.join("\n");
730
+ assert.ok(!allLogs.includes("这是 agent 的最终总结"), "★ 总结正文绝不能进日志");
731
+ assert.ok(allLogs.split("\n").some((l) => /任务通知 .*len=\d+/.test(l)), "日志只记长度");
737
732
  } finally {
738
733
  await rt.close();
739
734
  }
740
735
  });
741
736
 
742
- test("★ 回答回传:turn/end 瞬间正文还没落库 → 短重试后仍能回传(不是直接降级)", async () => {
737
+ test("★ 任务通知:turn/end 瞬间正文还没落库 → 短重试后仍带总结(不是直接降级标题)", async () => {
743
738
  let call = 0;
744
739
  const rt = await setupRelayBot({
745
740
  assistantText: () => {
746
741
  call += 1;
747
- // 第一次「ok 但空」(真实竞态),第二次拿到正文
748
- return call === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后拿到的回答" };
742
+ return call === 1 ? { ok: true, text: "" } : { ok: true, text: "重试后拿到的总结" };
749
743
  }
750
744
  });
751
745
  try {
@@ -753,68 +747,52 @@ test("★ 回答回传:turn/end 瞬间正文还没落库 → 短重试后仍
753
747
  await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
754
748
  await rt.inbound("retry-1", "你好");
755
749
  rt.turnEnd({ turn: 1, seq: 31 });
756
- const send = await waitFor(() => rt.sends()[0], { label: "重试后的回答回传" });
757
- assert.equal(send.body.markdown.content, "重试后拿到的回答", "ok 但空 → 补一次再发,不应直接降级");
750
+ const send = await waitFor(() => rt.sends()[0], { label: "重试后的通知" });
751
+ const content = send.body.markdown.content;
752
+ assert.match(content, /^✅ 任务完成/);
753
+ assert.match(content, /\n\n重试后拿到的总结$/);
758
754
  assert.equal(rt.sub.lastAssistantCalls.length, 2, "应恰好重试一次");
759
- assert.equal(rt.bot.status().answers, 1);
760
755
  } finally {
761
756
  await rt.close();
762
757
  }
763
758
  });
764
759
 
765
- test("★ 回答回传边界:超过 TTL 不回传、超长截断+注明、正文绝不进日志、非 completed 带状态行", async () => {
766
- // ① TTL:注入 40ms,入站后等过期再 turn/end → 只发标题通知
767
- {
768
- const rt = await setupRelayBot({ assistantText: { ok: true, text: "过期不该发的回答" }, bot: { answerRelayTtlMs: 40 } });
769
- try {
770
- rt.bot.start();
771
- await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
772
- await rt.inbound("ttl-1", "你好");
773
- await sleep(120); // 超过 40ms TTL
774
- rt.turnEnd({ turn: 1, seq: 21, at: Date.now() });
775
- const send = await waitFor(() => rt.sends()[0], { label: "超时后的标题通知" });
776
- assert.match(send.body.markdown.content, /^✅ 任务完成/, "超过 10 分钟语义(测试缩放为 40ms)应回到标题通知");
777
- assert.ok(!send.body.markdown.content.includes("过期不该发的回答"));
778
- } finally {
779
- await rt.close();
780
- }
781
- }
782
-
783
- // ② 截断 + 正文不进日志 + 非 completed 状态行
784
- {
785
- const longAnswer = "开头" + "长".repeat(300) + "SECRET-ANSWER-TAIL";
786
- const rt = await setupRelayBot({
787
- assistantText: { ok: true, text: longAnswer },
788
- bot: { answerMaxChars: 50 }
789
- });
790
- try {
791
- rt.bot.start();
792
- await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
793
- await rt.inbound("trunc-1", "来点长的");
794
- rt.turnEnd({ turn: 1, seq: 22 });
795
- const send = await waitFor(() => rt.sends()[0], { label: "截断后的回答" });
796
- const content = send.body.markdown.content;
797
- assert.ok(content.endsWith(ANSWER_TRUNCATED_NOTE), `超长回答要以「${ANSWER_TRUNCATED_NOTE}」结尾`);
798
- assert.equal(content.length, 50 + ANSWER_TRUNCATED_NOTE.length, "正文部分必须恰好截到 answerMaxChars");
799
- assert.ok(!content.includes("SECRET-ANSWER-TAIL"));
800
- assert.ok(content.startsWith("开头"));
801
- // 日志只打长度,不打正文
802
- const allLogs = rt.lines.join("\n");
803
- assert.ok(!allLogs.includes("SECRET-ANSWER-TAIL"), "★ 回答正文绝不能进日志");
804
- assert.ok(!allLogs.includes("长".repeat(40)), "★ 回答正文片段也不能进日志");
805
- assert.ok(allLogs.split("\n").some((l) => /回答回传 .* len=\d+/.test(l)), "日志只记长度");
806
- assert.ok(allLogs.split("\n").some((l) => /已截断/.test(l)), "日志要标出截断");
807
-
808
- // ③ 非 completed:带一句状态行 + 正文(仍是一条消息)
809
- rt.sub.assistantReply = { ok: true, text: "只跑了一半的结果" };
810
- await rt.inbound("err-1", "再试");
811
- rt.turnEnd({ turn: 2, seq: 23, reason: "error" });
812
- const errSend = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("只跑了一半的结果")), { label: "出错时回传" });
813
- assert.match(errSend.body.markdown.content, /^❌ 出错了/);
814
- assert.ok(errSend.body.markdown.content.includes("只跑了一半的结果"), "同一条消息里既给状态也给正文");
815
- } finally {
816
- await rt.close();
817
- }
760
+ test("★ 任务通知边界:超长正文按 1500 字截断+注明;出错带错误摘要;正文不进日志", async () => {
761
+ const longSummary = "开头" + "长".repeat(3000) + "SECRET-SUMMARY-TAIL";
762
+ const rt = await setupRelayBot({ assistantText: { ok: true, text: longSummary } });
763
+ try {
764
+ rt.bot.start();
765
+ await waitFor(() => rt.bot.status().authenticated, { label: "认证完成" });
766
+ await rt.inbound("trunc-1", "来点长的");
767
+ rt.turnEnd({ turn: 1, seq: 22 });
768
+ const send = await waitFor(() => rt.sends()[0], { label: "截断后的通知" });
769
+ const content = send.body.markdown.content;
770
+ assert.match(content, /^✅ 任务完成/);
771
+ assert.ok(content.endsWith(ANSWER_TRUNCATED_NOTE), `超长正文要以「${ANSWER_TRUNCATED_NOTE}」结尾`);
772
+ const bodyPart = content.split("\n\n").slice(1).join("\n\n");
773
+ assert.ok(Array.from(bodyPart).length <= 1500 + Array.from(ANSWER_TRUNCATED_NOTE).length + 2, "正文长度不得超过 1500 字(+截断提示)");
774
+ assert.ok(!content.includes("SECRET-SUMMARY-TAIL"));
775
+ const allLogs = rt.lines.join("\n");
776
+ assert.ok(!allLogs.includes("SECRET-SUMMARY-TAIL"), "★ 总结正文绝不能进日志");
777
+ assert.ok(!allLogs.includes("长".repeat(40)), "★ 正文片段也不能进日志");
778
+ assert.ok(allLogs.split("\n").some((l) => /任务通知 .* len=\d+/.test(l)), "日志只记长度");
779
+
780
+ // 出错:❌ 标题 + 错误摘要(同一条消息)
781
+ rt.sub.assistantReply = { ok: true, text: "只跑了一半的结果" };
782
+ await rt.inbound("err-1", "再试");
783
+ rt.turnEnd({ turn: 2, seq: 23, reason: "error" });
784
+ const errSend = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("只跑了一半的结果")), { label: "出错通知" });
785
+ assert.match(errSend.body.markdown.content, /^❌ 出错了/);
786
+ assert.match(errSend.body.markdown.content, /\n\n只跑了一半的结果$/);
787
+
788
+ // 被拦下(blocked):状态行 + 总结
789
+ rt.sub.assistantReply = { ok: true, text: "被安全策略拦截的说明" };
790
+ await rt.inbound("block-1", "危险操作");
791
+ rt.turnEnd({ turn: 3, seq: 24, reason: "blocked" });
792
+ const blocked = await waitFor(() => rt.sends().find((f) => f.body.markdown.content.includes("被安全策略拦截的说明")), { label: "被拦通知" });
793
+ assert.match(blocked.body.markdown.content, /^⛔ 被拦下了/);
794
+ } finally {
795
+ await rt.close();
818
796
  }
819
797
  });
820
798
 
@@ -52,7 +52,12 @@ import WebSocket from "ws";
52
52
 
53
53
  import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
54
54
  // 通道感知格式化(2026-11-08:企微/钉钉 markdown 不支持表格,发前统一适配;飞书/微信各有分支)
55
- import { formatForChannel } from "./im-format.mjs";
55
+ import {
56
+ SUMMARY_MAX_CHARS,
57
+ buildTurnNotification,
58
+ extractTurnSummary,
59
+ formatForChannel
60
+ } from "./im-format.mjs";
56
61
  // 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在**进模型前**拦下执行;
57
62
  // 解析/文案/能力门禁三家共用(与会员升级文案同口径)。
58
63
  import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
@@ -2053,102 +2058,42 @@ export function createWecomBot(options = {}) {
2053
2058
  }
2054
2059
 
2055
2060
  /**
2056
- * 这个 turn/end 是不是「我们刚转发进去的那次对话」的收尾?
2061
+ * 任务通知(完成/出错/被拦/停止):**一条**消息 = 标题行 + 总结正文。
2057
2062
  *
2058
- * 判据(宁可不回传,也不要瞎回传):
2059
- * · 必须**实时** turn/end(`fromSnapshot` = 重连时补报的旧 turn,绝不能拿旧回答当新回答);
2060
- * · 会话必须是最近一次入站转发成功的那个;
2061
- * · 距那次入站 < TTL(默认 10 分钟)—— 超时就当另一次对话,走原来的标题通知;
2062
- * · 事件时间不能早于入站时间(给 5s 时钟/批处理余量)。
2063
+ * 正文优先订阅器 `lastAssistantText({sessionId})`(turn/end 瞬间可能还没落库 → 沿用旧短重试),
2064
+ * 取不到再用事件字段、再取不到降级"仅标题";正文按企业微信通道渲染并限制 1500 字。
2065
+ * **日志只打长度/来源,绝不打印正文**(可能有代码/密钥/隐私)。
2063
2066
  */
2064
- function shouldRelayAnswer(node) {
2065
- if (!answerRelayEnabled) return false;
2066
- if (node.kind !== NODE_KINDS.TURN_END) return false;
2067
- if (node.fromSnapshot === true) return false;
2068
- if (!lastInbound.sessionId || node.sessionId !== lastInbound.sessionId) return false;
2069
- const at = now();
2070
- if (at - lastInbound.at >= answerRelayTtlMs) return false;
2071
- if (Number.isFinite(node.at) && node.at > 0 && node.at < lastInbound.at - 5000) return false;
2072
- return true;
2067
+ async function sendTurnNotice(node, target) {
2068
+ let summary = { text: "", source: "none" };
2069
+ try {
2070
+ summary = await extractTurnSummary({
2071
+ node,
2072
+ subscriber: answerRelayEnabled ? subscriber : null,
2073
+ sessionId: node.sessionId,
2074
+ maxChars: SUMMARY_MAX_CHARS,
2075
+ retryDelays: answerRelayEnabled ? ANSWER_FETCH_RETRY_DELAYS_MS : null
2076
+ });
2077
+ } catch { summary = { text: "", source: "none" }; }
2078
+ if (stopped) return;
2079
+ const text = buildTurnNotification({
2080
+ node,
2081
+ channel: "wecom",
2082
+ summary: summary.text,
2083
+ sessionTitle: titleOfSession(node.sessionId, ""),
2084
+ maxChars: SUMMARY_MAX_CHARS
2085
+ });
2086
+ if (!text) return;
2087
+ log.info(`任务通知 kind=${node.kind} reason=${node.reason || "?"} summary=${summary.source} len=${Array.from(summary.text).length}`);
2088
+ sendMarkdown(target, text);
2073
2089
  }
2074
2090
 
2075
2091
  /**
2076
- * 回答回传:把 agent 的最终回答正文发回企微(不再发「✅ 任务完成|标题」那条)。
2077
- *
2078
- * 取正文用事件订阅器的 `lastAssistantText({sessionId})`(dsh-events 已有,永不抛);
2079
- * 取不到 → **降级退回原来的标题通知**(不丢消息,也不假装有正文);
2080
- * 日志只打长度/会话短 id,**绝不打印正文**(回答里可能有代码/密钥/隐私)。
2092
+ * 事件订阅 → 出站通知。
2093
+ * · turn/end、session-error:**一条**消息 = 标题行 + 总结正文(sendTurnNotice;同 turn 已去重)
2094
+ * · 审批/提问/计划:优先可点卡片(**不混入总结**),卡片失败退回文本
2095
+ * · 其它(简报/额度/会员/故障):沿用 toMarkdownNotification
2081
2096
  */
2082
- async function relayAnswer(node, sessionId) {
2083
- const target = state.boundChatId || targetChatId;
2084
- if (!target) {
2085
- const at = now();
2086
- if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
2087
- lastNoTargetLogAt = at;
2088
- log.info("有回答要回传,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
2089
- }
2090
- return;
2091
- }
2092
- let text = "";
2093
- let why = "";
2094
- const sub = subscriber;
2095
- if (!sub || typeof sub.lastAssistantText !== "function") {
2096
- why = "no_subscriber";
2097
- } else {
2098
- for (const delay of ANSWER_FETCH_RETRY_DELAYS_MS) {
2099
- if (delay > 0) {
2100
- await sleepTimer(delay);
2101
- if (stopped) return; // 等待期间桥已退出:不再发
2102
- }
2103
- try {
2104
- const r = await sub.lastAssistantText({ sessionId });
2105
- if (r && r.ok && typeof r.text === "string" && r.text.trim()) {
2106
- text = r.text.trim();
2107
- break;
2108
- }
2109
- why = String((r && (r.code || r.message)) || "empty");
2110
- // 硬失败(page-failed / session-not-found)重试也没意义;只有「ok 但正文还没落库」才补一次
2111
- if (!(r && r.ok)) break;
2112
- } catch (e) {
2113
- why = truncateText(e && e.message ? e.message : e, 60);
2114
- break;
2115
- }
2116
- }
2117
- }
2118
-
2119
- // 降级:拿不到正文 → 退回标题通知(与改动前的行为一致)
2120
- if (!text) {
2121
- const fallback = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
2122
- if (!fallback) return;
2123
- if (!notifyRateOk()) {
2124
- log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
2125
- return;
2126
- }
2127
- state.notified += 1;
2128
- sendMarkdown(target, fallback);
2129
- log.info(`取不到回答正文(${truncateText(why || "empty", 60)}),已降级为标题通知`);
2130
- return;
2131
- }
2132
-
2133
- const truncated = text.length > answerMaxChars;
2134
- let body = truncated ? `${text.slice(0, answerMaxChars)}${ANSWER_TRUNCATED_NOTE}` : text;
2135
- // 非正常结束(出错/被拦/中止)时保留一句状态行,避免"看到回答却不知道它不完整"
2136
- const reasonKind = String(node.reason || "");
2137
- if (reasonKind && reasonKind !== "completed") {
2138
- const status = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
2139
- if (status) body = `${status}\n\n${body}`;
2140
- }
2141
- if (!notifyRateOk()) {
2142
- log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
2143
- return;
2144
- }
2145
- state.notified += 1;
2146
- state.answers += 1;
2147
- const sent = sendMarkdown(target, body);
2148
- // 只打长度与截断标记:正文绝不能进日志
2149
- log.info(`回答回传 chat=${chatIdHash(target)} len=${body.length}${truncated ? "(已截断)" : ""}${sent && sent.ok ? "" : ` 发送失败=${(sent && sent.reason) || "?"}`}`);
2150
- }
2151
-
2152
2097
  function onEventNode(node) {
2153
2098
  if (!node || typeof node !== "object") return;
2154
2099
  if (typeof node.sessionId === "string" && node.sessionId) {
@@ -2156,19 +2101,31 @@ export function createWecomBot(options = {}) {
2156
2101
  scheduleSessionRefresh();
2157
2102
  }
2158
2103
  if (stopped || !notifyAllowed || !state.authenticated) return;
2159
- // ── 回答回传:入站后的同一会话 turn/end → 发回答正文(消费一次)──
2160
- if (shouldRelayAnswer(node)) {
2161
- const key = notifyKey(node);
2104
+ const key = notifyKey(node);
2105
+ // ── 任务完成/出错:标题 + 总结正文(一次 turn 只发一条)──
2106
+ if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
2162
2107
  if (key && !rememberNotifyKey(key)) return;
2163
- const sid = node.sessionId;
2108
+ const target = state.boundChatId || targetChatId;
2109
+ if (!target) {
2110
+ const at = now();
2111
+ if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
2112
+ lastNoTargetLogAt = at;
2113
+ log.info("有任务通知要发,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
2114
+ }
2115
+ return;
2116
+ }
2117
+ if (!notifyRateOk()) {
2118
+ log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
2119
+ return;
2120
+ }
2121
+ state.notified += 1;
2164
2122
  lastInbound = { sessionId: "", at: 0 }; // 同一次入站只回传一次
2165
- void relayAnswer(node, sid);
2123
+ void sendTurnNotice(node, target);
2166
2124
  return;
2167
2125
  }
2168
2126
  // ── 审批类事件:优先发**可点按钮的卡片**(允许一次/拒绝),发不出去再退回 markdown ──
2169
2127
  if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
2170
2128
  const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
2171
- const key = notifyKey(node);
2172
2129
  if (key && !rememberNotifyKey(key)) return;
2173
2130
  const requestId = String(node.eventId || "");
2174
2131
  const target = state.boundChatId || targetChatId;
@@ -2200,11 +2157,9 @@ export function createWecomBot(options = {}) {
2200
2157
  }
2201
2158
  const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
2202
2159
  if (!markdown) return;
2203
- const key = notifyKey(node);
2204
2160
  if (key && !rememberNotifyKey(key)) return;
2205
2161
  const target = state.boundChatId || targetChatId;
2206
2162
  if (!target) {
2207
- // 首次使用:用户还没给机器人发过话,也没有配 targetChatId → 无处可推(如实记日志,不刷屏)
2208
2163
  const at = now();
2209
2164
  if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
2210
2165
  lastNoTargetLogAt = at;
@@ -2232,7 +2187,6 @@ export function createWecomBot(options = {}) {
2232
2187
 
2233
2188
  function rememberNotifyKey(key) {
2234
2189
  const at = now();
2235
- // 有界 + TTL:Map 保插入序,超上限丢最旧
2236
2190
  for (const [k, t] of notifiedKeys) {
2237
2191
  if (at - t > 10 * 60 * 1000) notifiedKeys.delete(k);
2238
2192
  else break;
package/dsh-setup.mjs CHANGED
@@ -149,7 +149,7 @@ function findDepWs(startDir) {
149
149
  * 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
150
150
  * 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
151
151
  */
152
- const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "im-commands.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
152
+ const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "im-commands.mjs", "apply-pending-plugin.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
153
153
 
154
154
  /**
155
155
  * 把"客户端脚本"补齐到配置目录。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.12-beta.6",
3
+ "version": "0.8.0",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -56,7 +56,7 @@
56
56
  "test:plugin": "DSH_PROFILE_DIR= DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 DSH_RELAY_BUNDLED_RUNTIME=/nonexistent-dsh-bundled NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
57
57
  "_comment:test:bridge": "⚠️ 必须 --test-concurrency=1(与 test:plugin 对齐):tunnel-auth 有一条「bridge 5 秒内必须重连」的硬期限,并行跑时新增用例抢 CPU 会把它撞破 → 偶发假红(实测单独跑 2/2 通过、并发跑偶发 0/2)。假红比慢更贵。",
58
58
  "test:bridge": "DSH_PROFILE_DIR= DSH_RELAY_DEFAULT_API=http://127.0.0.1:1 NODE_OPTIONS=\"--require $PWD/scripts/test-net-guard.cjs ${NODE_OPTIONS:-}\" node --test --test-concurrency=1 clients/dsh-remote/test/*.test.mjs",
59
- "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/agent/index.mjs && node --check clients/dsh-remote/agent/version.mjs && node --check clients/dsh-remote/agent/detect.mjs && node --check clients/dsh-remote/agent/permission-bridge.mjs && node --check clients/dsh-remote/agent/proc.mjs && node --check clients/dsh-remote/agent/events.mjs && node --check clients/dsh-remote/agent/codex.mjs && node --check clients/dsh-remote/agent/gemini.mjs && node --check clients/dsh-remote/agent/opencode.mjs && node --check clients/dsh-remote/agent/agent-e2e-codex.mjs && node --check clients/dsh-remote/agent/agent-e2e-real.mjs && node --check clients/dsh-remote/push-reporter.mjs && node --check clients/dsh-remote/decision-poller.mjs && node --check clients/dsh-remote/approval-coordinator.mjs && node --check clients/dsh-remote/wecom-bot.mjs && node --check clients/dsh-remote/im-format.mjs && node --check clients/dsh-remote/dingtalk-bot.mjs && node --check clients/dsh-remote/feishu-bot.mjs && node --check clients/dsh-remote/dingtalk-auth.mjs && node --check clients/dsh-remote/im-commands.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
59
+ "check": "node --check scripts/check-profile-activation.mjs && node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/wechat-channel.mjs && node --check clients/dsh-remote/wechat-runtime.mjs && node --check clients/dsh-remote/dsh-events.mjs && node --check clients/dsh-remote/agent/index.mjs && node --check clients/dsh-remote/agent/version.mjs && node --check clients/dsh-remote/agent/detect.mjs && node --check clients/dsh-remote/agent/permission-bridge.mjs && node --check clients/dsh-remote/agent/proc.mjs && node --check clients/dsh-remote/agent/events.mjs && node --check clients/dsh-remote/agent/codex.mjs && node --check clients/dsh-remote/agent/gemini.mjs && node --check clients/dsh-remote/agent/opencode.mjs && node --check clients/dsh-remote/agent/agent-e2e-codex.mjs && node --check clients/dsh-remote/agent/agent-e2e-real.mjs && node --check clients/dsh-remote/push-reporter.mjs && node --check clients/dsh-remote/decision-poller.mjs && node --check clients/dsh-remote/approval-coordinator.mjs && node --check clients/dsh-remote/wecom-bot.mjs && node --check clients/dsh-remote/im-format.mjs && node --check clients/dsh-remote/dingtalk-bot.mjs && node --check clients/dsh-remote/feishu-bot.mjs && node --check clients/dsh-remote/dingtalk-auth.mjs && node --check clients/dsh-remote/im-commands.mjs && node --check clients/dsh-remote/apply-pending-plugin.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && node --check packages/relay-router/src/index.mjs && node --check packages/relay-router/src/logger.mjs && bash -n deploy/install-open.sh && grep -q 'command -v setsid' deploy/install-open.sh && grep -q 'command -v ss' deploy/install-open.sh",
60
60
  "release:tarballs": "bash scripts/release-tarballs.sh",
61
61
  "sync:local": "node scripts/sync-local.mjs"
62
62
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.12-beta.6",
3
+ "version": "0.8.0",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",