wezard 1.2.26 → 1.2.28

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.
@@ -1455,13 +1455,27 @@ export const startMirror = (deps) => {
1455
1455
  const EARLY_LINK_MS = 3_000;
1456
1456
  /** 软收口静默期。后端只能说"这条消息写完了"(codebuddy) 时, 等这么久没有新 item
1457
1457
  * 才认定一轮结束。取值只需盖住"叙述消息落盘 → 紧随其后的 function_call 落盘"
1458
- * 这一段, 与模型思考/工具执行时长无关。 */
1459
- const SOFT_TURN_END_MS = 4_000;
1458
+ * 这一段, 与模型思考/工具执行时长无关。
1459
+ * codebuddy 默认 10s (实测 <40ms, 但留余量防 fs.watch 抖动 / 落盘延迟);
1460
+ * claude 默认 4s (硬信号兜底, 软收口极少触发)。可由 config 覆盖。 */
1461
+ const SOFT_TURN_END_DEFAULT_CLAUDE = 4_000;
1462
+ const SOFT_TURN_END_DEFAULT_CB = 10_000;
1463
+ const softTurnEndMsFor = (a) => cfg.wrc.mirror.softTurnEndMs ?? (backendForPath(a.jsonlPath).name === "codebuddy" ? SOFT_TURN_END_DEFAULT_CB : SOFT_TURN_END_DEFAULT_CLAUDE);
1460
1464
  const STREAM_SOFT_CAP = 18_000;
1461
1465
  const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
1462
- /** thinkStyle 兜底超时: turn 开始 2min 后仍未 conclude 强制软收口。
1463
- * HARD_TIMEOUT_MS (350s) 短, 气泡还活着时走 finishBriefBubble 路径。 */
1464
- const THINK_RESCUE_TIMEOUT_MS = 120_000;
1466
+ /** Standalone 发送前正文检查: 内容无正文 (纯 think 标记 / 工具行) 时,
1467
+ * 回队列等这么久。期间有新消息入队则重置; 到期且无新消息才发出。 */
1468
+ const STANDALONE_BODY_WAIT_MS = 8_000;
1469
+ // 判断 standalone 内容是否含有正文 (非纯 <think>…</think> / 工具行)。
1470
+ const standaloneHasBody = (content) => {
1471
+ // 剥掉 <think>…</think> 包裹 (含 tag header)
1472
+ const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, "")
1473
+ // 工具行
1474
+ .replace(/^🔧 .*/gm, "")
1475
+ .replace(/^↳ .*/gm, "")
1476
+ .trim();
1477
+ return stripped.length > 0;
1478
+ };
1465
1479
  const turnRegistry = new Map();
1466
1480
  const evictTurns = () => {
1467
1481
  const now = Date.now();
@@ -1641,12 +1655,22 @@ export const startMirror = (deps) => {
1641
1655
  };
1642
1656
  // Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
1643
1657
  // 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
1644
- const flushStandalone = (a) => {
1658
+ // 正文检查: 合并内容无正文 (纯 think/工具) 时, 回队列等 STANDALONE_BODY_WAIT_MS;
1659
+ // 期间有新消息入队 (parts 增长) 则重置; 到期且无新消息才强制发出。
1660
+ // force=true 跳过正文检查, 用于 teardown / detach / pre-card 等强制 drain 场景。
1661
+ const flushStandalone = (a, force = false) => {
1645
1662
  const buf = a.standaloneBuf;
1646
1663
  if (!buf)
1647
1664
  return;
1665
+ const merged = buf.parts.join("\n\n");
1666
+ // 无正文 & 队列有新增 → 再等一轮 (force 跳过)
1667
+ if (!force && !standaloneHasBody(merged) && buf.bodyWaitLen !== buf.parts.length) {
1668
+ buf.bodyWaitLen = buf.parts.length;
1669
+ buf.timer = setTimeout(() => flushStandalone(a), STANDALONE_BODY_WAIT_MS);
1670
+ return;
1671
+ }
1648
1672
  a.standaloneBuf = undefined;
1649
- sendStandalone(a, buf.parts.join("\n\n"));
1673
+ sendStandalone(a, merged);
1650
1674
  };
1651
1675
  const enqueueStandalone = (a, content) => {
1652
1676
  const ms = cfg.wrc.mirror.standaloneDebounceMs;
@@ -1697,7 +1721,7 @@ export const startMirror = (deps) => {
1697
1721
  if (!a.standaloneBuf)
1698
1722
  return;
1699
1723
  clearTimeout(a.standaloneBuf.timer);
1700
- flushStandalone(a);
1724
+ flushStandalone(a, true);
1701
1725
  };
1702
1726
  // Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
1703
1727
  // standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
@@ -1856,6 +1880,11 @@ export const startMirror = (deps) => {
1856
1880
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
1857
1881
  // 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
1858
1882
  // raw=true skips withSessionTag (used when content already contains the linked tag header).
1883
+ // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
1884
+ // standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
1885
+ // 把 finishBubble 的 replyStream promise 链入 standalonePending, 让后续
1886
+ // standalone 自然排在 finish 之后; 额外加一小段延迟留给客户端渲染。
1887
+ const BUBBLE_FINISH_SETTLE_MS = 600;
1859
1888
  const finishBubble = async (a, b, content, raw = false) => {
1860
1889
  if (!b || b.done)
1861
1890
  return;
@@ -1865,16 +1894,28 @@ export const startMirror = (deps) => {
1865
1894
  clearTimeout(b.earlyTimer);
1866
1895
  b.earlyTimer = undefined;
1867
1896
  }
1897
+ // think-style: 清除待发的 flush 定时器, 避免 doStreamThink 在 done 后空跑。
1898
+ if (a.briefBubble === b && a.thinkFlushTimer) {
1899
+ clearTimeout(a.thinkFlushTimer);
1900
+ a.thinkFlushTimer = undefined;
1901
+ }
1868
1902
  if (a.briefBubble === b)
1869
1903
  a.briefBubble = undefined;
1870
- try {
1871
- await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
1872
- }
1873
- catch (e) {
1874
- log.warn({ sessionId: a.sessionId, err: e.message }, "brief: bubble finish failed; standalone fallback");
1875
- if (content.trim())
1876
- raw ? sendRaw(a, content) : sendStandalone(a, content);
1877
- }
1904
+ const p = (async () => {
1905
+ try {
1906
+ await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
1907
+ // WeCom 客户端留一点时间完成打字机动画, 再放行 standalone 队列。
1908
+ await new Promise((r) => setTimeout(r, BUBBLE_FINISH_SETTLE_MS));
1909
+ }
1910
+ catch (e) {
1911
+ log.warn({ sessionId: a.sessionId, err: e.message }, "brief: bubble finish failed; standalone fallback");
1912
+ if (content.trim())
1913
+ raw ? sendRaw(a, content) : sendStandalone(a, content);
1914
+ }
1915
+ })();
1916
+ // 链入 standalonePending, 后续 sendStandalone/sendRaw 自然等 finish 完成。
1917
+ a.standalonePending = a.standalonePending.then(() => p).catch(() => undefined);
1918
+ return p;
1878
1919
  };
1879
1920
  const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
1880
1921
  // think-style tag: 紧跟在 `<think>` 之后, 不在整条消息最前面 —— `<think>🧙 …`。
@@ -1886,17 +1927,36 @@ export const startMirror = (deps) => {
1886
1927
  // think-style: 把当前累积的 thinking 作为 `<think>🧙 …` 流进活着的 loading 气泡
1887
1928
  // (finish=false, 气泡不关)。收口时再补 `</think>\n\n<final>` 并 finish。
1888
1929
  // raw 发送: tag 已在 <think> 内, 不再经 withSessionTag 外包。
1889
- const streamThink = (a) => {
1930
+ // streamPending 首次调用时才真正开流 (fix: off-by-one race)。
1931
+ // fix: 加去抖 (FLUSH_MS) + 背压 (await 上一次完成), 消除 SDK 串行队列积压。
1932
+ const doStreamThink = async (a) => {
1890
1933
  const b = a.briefBubble;
1891
1934
  const think = a.briefThinking?.trim();
1892
1935
  if (!b || b.done || !think)
1893
1936
  return;
1894
- client
1895
- .replyStream(b.frame, b.streamId, openThink(a.target, think), false)
1896
- .catch((e) => log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed"));
1937
+ // 首条 replyStream 延迟到这里发出 (streamPending), 携带真实内容, 避免空开流竞态。
1938
+ b.streamPending = false;
1939
+ try {
1940
+ await client.replyStream(b.frame, b.streamId, openThink(a.target, think), false);
1941
+ log.debug({ sessionId: a.sessionId, streamId: b.streamId, thinkLen: think.length }, "brief: think stream ok");
1942
+ }
1943
+ catch (e) {
1944
+ log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed");
1945
+ }
1946
+ };
1947
+ const scheduleThinkFlush = (a) => {
1948
+ if (!a.briefBubble || a.briefBubble.done || a.thinkFlushTimer || a.thinkFlushing)
1949
+ return;
1950
+ a.thinkFlushTimer = setTimeout(() => {
1951
+ a.thinkFlushTimer = undefined;
1952
+ if (a.thinkFlushing)
1953
+ return; // 上一次还在飞 → 下次 pushThink 再排
1954
+ a.thinkFlushing = true;
1955
+ void doStreamThink(a).finally(() => { a.thinkFlushing = false; });
1956
+ }, FLUSH_MS);
1897
1957
  };
1898
1958
  // think-style 单一入口: 一段中间过程 (reasoning / 中途文本 / 工具调用) 进 think。
1899
- // 气泡还活 → 累积进 briefThinking 并全量刷进气泡; 否则 (无气泡 turn / 气泡已收口)
1959
+ // 气泡还活 → 累积进 briefThinking 并排一次去抖 flush; 否则 (无气泡 turn / 气泡已收口)
1900
1960
  // 只累积, 绝不单独 standalone —— 中间纯思考过程不下发消息, 等正式文本回复出现时
1901
1961
  // 与正文拼成一条 `<think>…</think>\n\n正文` 整段下发 (见 concludeBriefTurn)。
1902
1962
  const pushThink = (a, chunk) => {
@@ -1905,7 +1965,7 @@ export const startMirror = (deps) => {
1905
1965
  return;
1906
1966
  a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
1907
1967
  if (a.briefBubble && !a.briefBubble.done)
1908
- streamThink(a);
1968
+ scheduleThinkFlush(a);
1909
1969
  };
1910
1970
  // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
1911
1971
  const openBriefTurn = (a, q) => {
@@ -1916,18 +1976,11 @@ export const startMirror = (deps) => {
1916
1976
  a.briefConcluded = false;
1917
1977
  a.briefLastText = undefined;
1918
1978
  a.briefThinking = undefined;
1919
- if (a.thinkRescueTimer) {
1920
- clearTimeout(a.thinkRescueTimer);
1921
- a.thinkRescueTimer = undefined;
1922
- }
1923
- if (thinkStyle) {
1924
- a.thinkRescueTimer = setTimeout(() => {
1925
- if (a.briefTurnId === q.turnId && !a.briefConcluded) {
1926
- log.warn({ sessionId: a.sessionId, turnId: q.turnId }, "brief: thinkStyle rescue timeout — forcing close");
1927
- closeBriefTurn(a, true);
1928
- }
1929
- }, THINK_RESCUE_TIMEOUT_MS);
1979
+ if (a.thinkFlushTimer) {
1980
+ clearTimeout(a.thinkFlushTimer);
1981
+ a.thinkFlushTimer = undefined;
1930
1982
  }
1983
+ a.thinkFlushing = false;
1931
1984
  log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
1932
1985
  };
1933
1986
  // WeCom 侧发起一个 turn: 立刻挂 loading 气泡当 ack, 再决定是马上激活还是排队。
@@ -1970,6 +2023,9 @@ export const startMirror = (deps) => {
1970
2023
  // 活跃 turn 的 thinking 才是本气泡的内容; 排队 turn 没有 thinking, 空收即可。
1971
2024
  const think = (a.briefBubble === bubble) ? a.briefThinking?.trim() : undefined;
1972
2025
  const content = think ? openThink(a.target, think) : withSessionTag(a.target, " ");
2026
+ // 标记: thinking 快照已写进气泡, 后续 concludeBriefTurn 走 else 分支时不再重复发 thinking。
2027
+ if (think)
2028
+ bubble.thinkSnapshotFlushed = true;
1973
2029
  void finishBubble(a, bubble, content, true);
1974
2030
  }
1975
2031
  else {
@@ -1980,7 +2036,11 @@ export const startMirror = (deps) => {
1980
2036
  // 首条 item 到达时清除 (handleBriefItem 路径)。用显式 bubble ref 而非 a.briefBubble,
1981
2037
  // 因为排队中的 turn 还没成为活跃 turn, a.briefBubble 指向的是前一个。
1982
2038
  // think-style: 不设 earlyTimer —— 不发链接, 气泡留着等 thinking / 答复流进来。
1983
- if (!thinkStyle)
2039
+ // codebuddy: jsonl 按完整消息落盘 (status:"completed"), 不像 Claude Code 增量写,
2040
+ // 首条 item 常在 3s 之后才到达 → earlyTimer 提前收口气泡 → 结论被迫走 standalone。
2041
+ // 跳过 earlyTimer, 让气泡等到真正的内容写入 (hardTimer 兜底 6min 窗口)。
2042
+ const skipEarly = thinkStyle || backendForPath(a.jsonlPath).name === "codebuddy";
2043
+ if (!skipEarly)
1984
2044
  bubble.earlyTimer = setTimeout(() => {
1985
2045
  if (bubble.done)
1986
2046
  return;
@@ -1994,10 +2054,12 @@ export const startMirror = (deps) => {
1994
2054
  openBriefTurn(a, q);
1995
2055
  }
1996
2056
  try {
1997
- // think-style: 用户一发消息立刻回 `<think>🧙 ` 开头 (tag 在 <think> 内), 不再先占位 `…`。
1998
- // 后续 thinking / 中途文本经 streamThink 全量替换续写, 收口时补 `</think>\n\n<final>`。
2057
+ // think-style: 延迟首条 replyStream 到首个内容到达 (streamPending=true),
2058
+ // 避免空 `<think>` 与紧随的 pushThink 产生两次快速 replyStream 触发 WeCom
2059
+ // 服务端合并/丢弃 (off-by-one)。flushStreamPending 在 pushThink/doStreamThink 首次
2060
+ // 调用时开流, 保证只有一次 replyStream 携带真实内容。
1999
2061
  if (thinkStyle) {
2000
- await client.replyStream(frame, streamId, openThink(a.target, ""), false);
2062
+ bubble.streamPending = true;
2001
2063
  }
2002
2064
  else {
2003
2065
  await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false); // 挂住 loading 气泡, 不关闭
@@ -2069,7 +2131,10 @@ export const startMirror = (deps) => {
2069
2131
  else {
2070
2132
  // 无气泡 turn / 气泡已收口: 中间 thinking 不单独 standalone, 累积的整段思考与
2071
2133
  // 正式正文拼成一条下发 (think 取自 stripTrailingBody, 已摘掉尾部等于正文的重复段)。
2072
- const t = think;
2134
+ // fix #1: 若 hardTimer 已把 thinking 快照写进气泡 (thinkSnapshotFlushed), 只发 body,
2135
+ // 不再重复发 thinking —— 用户已经在气泡里看到了完整 thinking。
2136
+ const snapshotFlushed = a.briefBubble?.thinkSnapshotFlushed;
2137
+ const t = snapshotFlushed ? undefined : think;
2073
2138
  if (t)
2074
2139
  sendRaw(a, `${openThink(a.target, t)}</think>\n\n${body}`);
2075
2140
  else
@@ -2110,13 +2175,18 @@ export const startMirror = (deps) => {
2110
2175
  if (thinkStyle) {
2111
2176
  // 已结论 → 气泡已在 concludeBriefTurn 的 else 分支通过 sendRaw 发出
2112
2177
  // think+body 整条消息,这里只需收掉气泡(避免重复下发)。
2113
- // 未结论 → 不发纯 think 孤消息。streamThink 已提供实时预览,
2114
- // 标记 done 阻止后续 streamThink,等 concludeBriefTurn 合并下发。
2178
+ // 未结论 → doStreamThink 可能已把半成品 thinking 流进气泡 (无 </think> 闭合),
2179
+ // 用闭合的 thinking 快照收口, 避免 WeCom 渲染标签未闭合的乱排版。
2180
+ // 如果连 thinking 都没有 (streamPending 还在), 空收。
2115
2181
  if (a.briefConcluded) {
2116
2182
  void finishBriefBubble(a, withSessionTag(a.target, " "), true);
2117
2183
  }
2118
2184
  else {
2119
- a.briefBubble.done = true;
2185
+ const think = a.briefThinking?.trim();
2186
+ const content = think
2187
+ ? `${openThink(a.target, think)}</think>`
2188
+ : withSessionTag(a.target, " ");
2189
+ void finishBriefBubble(a, content, true);
2120
2190
  }
2121
2191
  }
2122
2192
  else {
@@ -2132,10 +2202,11 @@ export const startMirror = (deps) => {
2132
2202
  a.briefConcluded = false;
2133
2203
  a.briefLastText = undefined;
2134
2204
  a.briefThinking = undefined;
2135
- if (a.thinkRescueTimer) {
2136
- clearTimeout(a.thinkRescueTimer);
2137
- a.thinkRescueTimer = undefined;
2205
+ if (a.thinkFlushTimer) {
2206
+ clearTimeout(a.thinkFlushTimer);
2207
+ a.thinkFlushTimer = undefined;
2138
2208
  }
2209
+ a.thinkFlushing = false;
2139
2210
  // 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
2140
2211
  const next = a.briefQueue?.shift();
2141
2212
  if (next)
@@ -2154,8 +2225,8 @@ export const startMirror = (deps) => {
2154
2225
  // 一个僵尸 turn 上来当活跃 turn, 白收一遍。
2155
2226
  for (const q of a.briefQueue ?? []) {
2156
2227
  closed++;
2157
- // think-style: 不发链接, 排队气泡直接空收。
2158
- void finishBubble(a, q.bubble, thinkStyle ? " " : briefDetailLink(q.turnId, a.target), thinkStyle);
2228
+ // think-style: 不发链接, 排队气泡收为"已取消"提示 (避免空白气泡闪过)。
2229
+ void finishBubble(a, q.bubble, thinkStyle ? withSessionTag(a.target, " (queued, cancelled)") : briefDetailLink(q.turnId, a.target), thinkStyle);
2159
2230
  recordTurnClose(q.turnId);
2160
2231
  }
2161
2232
  a.briefQueue = [];
@@ -2174,7 +2245,7 @@ export const startMirror = (deps) => {
2174
2245
  }
2175
2246
  if (a.standaloneBuf) {
2176
2247
  clearTimeout(a.standaloneBuf.timer);
2177
- flushStandalone(a);
2248
+ flushStandalone(a, true);
2178
2249
  }
2179
2250
  if (a.liveStream && !a.liveStream.closed) {
2180
2251
  closed++;
@@ -2316,10 +2387,15 @@ export const startMirror = (deps) => {
2316
2387
  if (item.quiet)
2317
2388
  return;
2318
2389
  // slash 命令 (/context…) 的 skill_output 即本轮答案 (无 final text 收口) —— 写进
2319
- // loading 气泡。非 slash 场景的 skill_output 是中间反馈, 照旧 standalone。
2390
+ // loading 气泡。非 slash 场景的 skill_output 是中间反馈:
2391
+ // think-style → 进 think 流 (与工具调用等中间过程一致);
2392
+ // 非 think-style → standalone。
2320
2393
  if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
2321
2394
  void finishBriefBubble(a, item.body);
2322
2395
  }
2396
+ else if (thinkStyle) {
2397
+ pushThink(a, item.body);
2398
+ }
2323
2399
  else {
2324
2400
  sendStandalone(a, item.body);
2325
2401
  }
@@ -2431,7 +2507,7 @@ export const startMirror = (deps) => {
2431
2507
  a.softEnd = undefined;
2432
2508
  }
2433
2509
  if (item.kind === "turn_end" && item.soft === true) {
2434
- a.softEnd = setTimeout(() => fireSoftTurnEnd(a), SOFT_TURN_END_MS);
2510
+ a.softEnd = setTimeout(() => fireSoftTurnEnd(a), softTurnEndMsFor(a));
2435
2511
  return;
2436
2512
  }
2437
2513
  // codebuddy: ExitPlanMode 若被本地先答, result 落盘即作废挂着的计划审批卡
@@ -2682,7 +2758,7 @@ export const startMirror = (deps) => {
2682
2758
  void finalizeStream(a, a.liveStream);
2683
2759
  if (a.standaloneBuf) {
2684
2760
  clearTimeout(a.standaloneBuf.timer);
2685
- flushStandalone(a);
2761
+ flushStandalone(a, true);
2686
2762
  }
2687
2763
  a.tail.stop();
2688
2764
  bySessionId.delete(a.sessionId);
@@ -4169,7 +4245,7 @@ export const startMirror = (deps) => {
4169
4245
  // 3) standalone 防抖里 (3s 默认) 还压着的合并文案 — 强制立刻发。
4170
4246
  if (a.standaloneBuf) {
4171
4247
  clearTimeout(a.standaloneBuf.timer);
4172
- flushStandalone(a);
4248
+ flushStandalone(a, true);
4173
4249
  }
4174
4250
  // 4) liveStream 半成品: 当前 acc 里有内容但还没 flush 出去 — 同步刷一刀,
4175
4251
  // 保留 stream 不 finalize (后续 tool_result 还要继续 append 同一气泡)。
@@ -4520,7 +4596,7 @@ export const startMirror = (deps) => {
4520
4596
  // race against the new stream's first content (Path B).
4521
4597
  if (a.standaloneBuf) {
4522
4598
  clearTimeout(a.standaloneBuf.timer);
4523
- flushStandalone(a);
4599
+ flushStandalone(a, true);
4524
4600
  }
4525
4601
  // /clear produces no assistant output; opening a stream would leave a
4526
4602
  // stale "…" + quoted-user bubble in WeCom. Skip the stream entirely on