wezard 1.2.30 → 1.3.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.
@@ -354,20 +354,6 @@ const oneLineSummary = (s, max = 40) => {
354
354
  const flat = s.replace(/\s+/g, " ").trim();
355
355
  return truncate(flat, max);
356
356
  };
357
- // think-style 收口去重: 软后端最终正文会先作为非 final 文本进 briefThinking, 收口
358
- // 再当正文用一次。剥掉 think 尾部恰好等于 body 的那段 (含其前的 \n\n 分隔), 让正文
359
- // 只出现在 </think> 之后。只剥完整尾段, 中途出现过的同句子片段不动 —— 那是真的思考。
360
- const stripTrailingBody = (think, body) => {
361
- if (!think || !body)
362
- return think || undefined;
363
- if (think === body)
364
- return undefined;
365
- if (think.endsWith(body)) {
366
- const cut = think.slice(0, think.length - body.length).replace(/\n{2,}$/, "").trimEnd();
367
- return cut || undefined;
368
- }
369
- return think;
370
- };
371
357
  // WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
372
358
  // code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
373
359
  // `<noreply@x>` etc. silently swallows the rest of the line plus the closing
@@ -536,8 +522,8 @@ const renderLine = (raw, deps) => {
536
522
  const sr = line.message?.stop_reason;
537
523
  const isFinal = sr === "end_turn";
538
524
  // final 是三态: true=终句, false=已知的中途输出, undefined=后端说不清 (软收口)。
539
- // 软后端不能填 false —— 那会让每句叙述都触发 earlyLinkBubble, 把 loading 气泡
540
- // 提前收成详情链接, 一句话答完的 turn 也要拆成两条消息。
525
+ // 软后端不能填 false —— 那会让每句叙述都被当成中途输出标记上 briefHadTool, 一句
526
+ // 话答完的 turn 也要被当成"有工具"处理。
541
527
  const textFinal = isFinal ? true : line.softTurnEnd ? undefined : false;
542
528
  let pending = [];
543
529
  const flushPending = () => {
@@ -570,7 +556,7 @@ const renderLine = (raw, deps) => {
570
556
  if (t && !deps.isOwnAssistantSend?.(t))
571
557
  out.push({ kind: "text", body: t, final: textFinal });
572
558
  }
573
- else if (b?.type === "tool_use" && (deps.includeTools || deps.thinkStyle)) {
559
+ else if (b?.type === "tool_use" && deps.includeTools) {
574
560
  const name = b.name ?? "tool";
575
561
  const toolUseId = b.id ?? "";
576
562
  // 同名扩展当前 group; 不同名先 flush 再起新组。
@@ -578,14 +564,13 @@ const renderLine = (raw, deps) => {
578
564
  flushPending();
579
565
  pending.push({ toolUseId, name, input: b.input });
580
566
  }
581
- else if (b?.type === "thinking" && deps.thinkStyle) {
582
- flushPending();
583
- const raw = typeof b.thinking === "string" ? b.thinking : typeof b.text === "string" ? b.text : "";
584
- const t = raw.trim();
585
- if (t)
586
- out.push({ kind: "thinking", body: t });
567
+ else if (b?.type === "thinking" && typeof b.thinking === "string") {
568
+ // codebuddy 的 reasoning 记录已被 normalizeLine 归一成这个形状。
569
+ // 只作为 CoT 进度源发出: onItem 里它唯一的下游是 brief 进度行, 不进正文。
570
+ const thought = b.thinking.trim();
571
+ if (thought)
572
+ out.push({ kind: "thinking", body: thought });
587
573
  }
588
- // thinking blocks otherwise intentionally skipped (只进详情页)
589
574
  }
590
575
  flushPending();
591
576
  // Emit per-line usage snapshot BEFORE turn_end so brief store gets the last
@@ -666,8 +651,9 @@ const renderLine = (raw, deps) => {
666
651
  // Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
667
652
  // fenced block or table in a way that breaks rendering — see splitMarkdown.
668
653
  const splitChunks = splitMarkdown;
669
- // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line.
670
- const TAG_HEADER_BUDGET = 32;
654
+ // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
655
+ // (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
656
+ const TAG_HEADER_BUDGET = 64;
671
657
  export const startMirrorTail = (deps) => {
672
658
  const { log } = deps;
673
659
  // A single logical tail follows ONE sessionId whose transcript file may move
@@ -1353,8 +1339,6 @@ const enqueue = (key, job) => {
1353
1339
  };
1354
1340
  export const startMirror = (deps) => {
1355
1341
  const { cfg, log, client } = deps;
1356
- // think-style: brief 模式下把 thinking 直接流进气泡, 且全程不下发任何详情链接。
1357
- const thinkStyle = cfg.wrc.mirror.thinkStyle && cfg.wrc.mirror.brief;
1358
1342
  // Multi-mirror: each (sessionId, target) pair is one Attachment. Same
1359
1343
  // sessionId reattaches → replace. Same target with different sessionId →
1360
1344
  // replace too (one WeCom chat can only show one mirror at a time).
@@ -1440,8 +1424,6 @@ export const startMirror = (deps) => {
1440
1424
  // for >60s mid-turn and we don't want to drop the bubble while it's chewing.
1441
1425
  const FLUSH_MS = 250;
1442
1426
  const HARD_TIMEOUT_MS = 350_000;
1443
- /** 首条 CLI 产出到达前的早期超时: 3s 无响应则先收气泡为详情链接。 */
1444
- const EARLY_LINK_MS = 3_000;
1445
1427
  /** 软收口静默期。后端只能说"这条消息写完了"(codebuddy) 时, 等这么久没有新 item
1446
1428
  * 才认定一轮结束。取值只需盖住"叙述消息落盘 → 紧随其后的 function_call 落盘"
1447
1429
  * 这一段, 与模型思考/工具执行时长无关。
@@ -1452,19 +1434,6 @@ export const startMirror = (deps) => {
1452
1434
  const softTurnEndMsFor = (a) => cfg.wrc.mirror.softTurnEndMs ?? (backendForPath(a.jsonlPath).name === "codebuddy" ? SOFT_TURN_END_DEFAULT_CB : SOFT_TURN_END_DEFAULT_CLAUDE);
1453
1435
  const STREAM_SOFT_CAP = 18_000;
1454
1436
  const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
1455
- /** Standalone 发送前正文检查: 内容无正文 (纯 think 标记 / 工具行) 时,
1456
- * 回队列等这么久。期间有新消息入队则重置; 到期且无新消息才发出。 */
1457
- const STANDALONE_BODY_WAIT_MS = 8_000;
1458
- // 判断 standalone 内容是否含有正文 (非纯 <think>…</think> / 工具行)。
1459
- const standaloneHasBody = (content) => {
1460
- // 剥掉 <think>…</think> 包裹 (含 tag header)
1461
- const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, "")
1462
- // 工具行
1463
- .replace(/^🔧 .*/gm, "")
1464
- .replace(/^↳ .*/gm, "")
1465
- .trim();
1466
- return stripped.length > 0;
1467
- };
1468
1437
  const turnRegistry = new Map();
1469
1438
  const evictTurns = () => {
1470
1439
  const now = Date.now();
@@ -1572,8 +1541,6 @@ export const startMirror = (deps) => {
1572
1541
  // Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
1573
1542
  // plain withSessionTag when no turnId is available (no active turn to link).
1574
1543
  const linkedTagPrefix = (target, turnId) => {
1575
- if (thinkStyle)
1576
- return ""; // think-style: 全程不带详情链接, 一律退回 withSessionTag。
1577
1544
  if (!turnId)
1578
1545
  return "";
1579
1546
  const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
@@ -1590,7 +1557,7 @@ export const startMirror = (deps) => {
1590
1557
  };
1591
1558
  const sendStandalone = (a, content) => {
1592
1559
  const chatId = stripPrincipalPrefix(a.target);
1593
- const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkChars - TAG_HEADER_BUDGET));
1560
+ const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
1594
1561
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
1595
1562
  a.standalonePending = a.standalonePending
1596
1563
  .then(async () => {
@@ -1642,52 +1609,16 @@ export const startMirror = (deps) => {
1642
1609
  log.warn({ sessionId: a.sessionId, err: e.message }, "pane preamble capture failed");
1643
1610
  }
1644
1611
  };
1645
- // thinkStyle 格式化: 将 merged standalone 内容分拣为 <think> 包裹的工具行和正文。
1646
- // 统一在发送层处理, pushThink / handleBriefItem 等上游只需发原始文本。
1647
- const formatThinkStandalone = (a, merged) => {
1648
- const lines = merged.split("\n");
1649
- const thinkLines = [];
1650
- const bodyLines = [];
1651
- for (const line of lines) {
1652
- if (line.startsWith("🔧 ") || line.startsWith("↳ "))
1653
- thinkLines.push(line);
1654
- else
1655
- bodyLines.push(line);
1656
- }
1657
- const think = thinkLines.join("\n").trim();
1658
- const body = bodyLines.join("\n").trim();
1659
- if (think && body)
1660
- return `${openThink(a.target, think)}</think>\n\n${body}`;
1661
- if (think)
1662
- return `${openThink(a.target, think)}</think>`;
1663
- return body;
1664
- };
1665
1612
  // Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
1666
- // 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
1667
- // 正文检查: 合并内容无正文 (纯 think/工具) 时, 回队列等 STANDALONE_BODY_WAIT_MS;
1668
- // 期间有新消息入队 (parts 增长) 则重置; 到期且无新消息才丢弃。
1669
- // force=true 跳过正文检查, 用于 teardown / detach / pre-card 等强制 drain 场景。
1670
- const flushStandalone = (a, force = false) => {
1613
+ // 0 关闭时退化为透传。flushStandalone 也用于 detach / teardown 时的 drain。
1614
+ const flushStandalone = (a) => {
1671
1615
  const buf = a.standaloneBuf;
1672
1616
  if (!buf)
1673
1617
  return;
1674
1618
  const merged = buf.parts.join("\n\n");
1675
- if (!force && !standaloneHasBody(merged)) {
1676
- if (buf.bodyWaitLen !== buf.parts.length) {
1677
- // 无正文 & 队列有新增 → 再等一轮, 看后续 item 是否带正文。
1678
- buf.bodyWaitLen = buf.parts.length;
1679
- buf.timer = setTimeout(() => flushStandalone(a), STANDALONE_BODY_WAIT_MS);
1680
- return;
1681
- }
1682
- // 8s 无新 item 且仍无正文 → 丢弃 (纯 think/工具行不值得单独发一条)。
1683
- a.standaloneBuf = undefined;
1684
- return;
1685
- }
1686
1619
  a.standaloneBuf = undefined;
1687
- // thinkStyle: 统一格式化 — 工具行包进 <think>, 正文留外面。
1688
- const content = thinkStyle ? formatThinkStandalone(a, merged) : merged;
1689
- if (content)
1690
- sendStandalone(a, content);
1620
+ if (merged)
1621
+ sendStandalone(a, merged);
1691
1622
  };
1692
1623
  const enqueueStandalone = (a, content) => {
1693
1624
  const ms = cfg.wrc.mirror.standaloneDebounceMs;
@@ -1738,7 +1669,7 @@ export const startMirror = (deps) => {
1738
1669
  if (!a.standaloneBuf)
1739
1670
  return;
1740
1671
  clearTimeout(a.standaloneBuf.timer);
1741
- flushStandalone(a, true);
1672
+ flushStandalone(a);
1742
1673
  };
1743
1674
  // Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
1744
1675
  // standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
@@ -1878,24 +1809,77 @@ export const startMirror = (deps) => {
1878
1809
  }
1879
1810
  };
1880
1811
  // ── Brief mode ────────────────────────────────────────────────────────
1881
- // 每个 turn 挂一条 loading 气泡 (起始 "…" finish=false, 不立刻关掉)。收口时:
1882
- // 无工具 → 直接把 Claude 最终文本 (或 slash 命令的 skill_output) 写进这条气泡。
1883
- // • 有工具气泡收成 "✴️ 详情链接", 最终文本另发一条 standalone。
1812
+ // 每个 turn 挂一条气泡: ack 时就写进 `tag 详情链接 …` (finish=false, 不立刻关掉),
1813
+ // 让群里从收到消息那一刻起就有详情页入口。收口时:
1814
+ // • 正文到位 `链接 正文` 覆盖这条气泡。
1815
+ // • 始终没正文 / 过 6min 窗口 → 以 ack 时那条纯 `链接` 收口。
1816
+ // • 气泡已收口或无气泡 turn (CLI 侧发起) → 正文另发一条 standalone。
1884
1817
  // 其它所有 item 只写入 turn detail store。
1885
1818
  // 能证明"assistant 已经在产出"的 item —— 见到它们才补开无气泡 turn。
1886
1819
  const BRIEF_TURN_OPENERS = new Set(["text", "tool_use", "tool_result", "skill_output"]);
1820
+ // ── CoT 进度行 (brief 气泡) ─────────────────────────────────────────
1821
+ // 一轮里正文要等 final text 才落地, 中间几十秒到几分钟气泡只有一个 "…"。这里把最新的
1822
+ // thinking / 工具调用覆盖进这条还没收口的气泡, 让群里看得到进展。三条硬约束:
1823
+ // • 只写"还没收口的气泡" —— 结论一到 finishBubble 就用 `链接 正文` 整条替换, 进度
1824
+ // 不会留在最终气泡里 (这是它与已下线的 thinkStyle 的分界线: 那次是把整轮 reasoning
1825
+ // 拼进正文, 单条体积翻几倍被 md-chunk 切成多页刷屏);
1826
+ // • 单行定长 (COT_MAX_CHARS), 无论 thinking 多长恒定一行, 不存在刷屏可能;
1827
+ // • 节流 —— 气泡刷新是整条内容重发, 高频 thinking 不能 1:1 打到 stream 上。
1828
+ const COT_MAX_CHARS = 100;
1829
+ const COT_FLUSH_MS = 1500;
1830
+ // 压成一行的进度片段。safeForMarkdown 必须先于截断: thinking 里一个裸反引号就能提前
1831
+ // 闭合代码段, 让后面的正文裸奔在链接旁边。尾随的省略号 = "还在进行中"; 截断与进行中
1832
+ // 共用同一个 …, 不叠加两个。
1833
+ const cotLine = (s) => {
1834
+ const flat = safeForMarkdown(s.replace(/\s+/g, " ").trim());
1835
+ return flat ? `${flat.slice(0, COT_MAX_CHARS)}…` : "";
1836
+ };
1837
+ const cotToolLabel = (calls) => calls.map((c) => `${c.name} ${renderToolInputCompact(c.input, 60)}`.trim()).join(" / ");
1838
+ const clearCot = (a) => {
1839
+ if (a.cotTimer) {
1840
+ clearTimeout(a.cotTimer);
1841
+ a.cotTimer = undefined;
1842
+ }
1843
+ a.cotText = undefined;
1844
+ };
1845
+ const flushCot = async (a) => {
1846
+ a.cotTimer = undefined;
1847
+ const text = a.cotText;
1848
+ a.cotText = undefined;
1849
+ const b = a.briefBubble;
1850
+ const turnId = a.briefTurnId;
1851
+ // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
1852
+ if (!text || !b || b.done || !turnId || a.briefConcluded)
1853
+ return;
1854
+ try {
1855
+ await client.replyStream(b.frame, b.streamId, `${briefDetailLink(turnId, a.target)} \`${text}\``, false);
1856
+ }
1857
+ catch (e) {
1858
+ log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
1859
+ }
1860
+ };
1861
+ /** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
1862
+ const updateBriefProgress = (a, raw) => {
1863
+ const b = a.briefBubble;
1864
+ if (!b || b.done || a.briefConcluded)
1865
+ return;
1866
+ const line = cotLine(raw);
1867
+ if (!line)
1868
+ return;
1869
+ a.cotText = line;
1870
+ if (a.cotTimer)
1871
+ return;
1872
+ a.cotTimer = setTimeout(() => void flushCot(a), COT_FLUSH_MS);
1873
+ };
1887
1874
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
1888
1875
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
1889
1876
  // target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
1890
1877
  const briefDetailLink = (turnId, target) => {
1891
1878
  const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
1892
1879
  const tag = tagOfKey(target);
1893
- return tag
1894
- ? `[${labelFor(tag)} #${tag}](${url}) ← View chat details`
1895
- : `[🧙](${url}) ← View chat details`;
1880
+ return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
1896
1881
  };
1897
1882
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
1898
- // 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
1899
1883
  // raw=true skips withSessionTag (used when content already contains the linked tag header).
1900
1884
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
1901
1885
  // standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
@@ -1907,17 +1891,10 @@ export const startMirror = (deps) => {
1907
1891
  return;
1908
1892
  b.done = true;
1909
1893
  clearTimeout(b.hardTimer);
1910
- if (b.earlyTimer) {
1911
- clearTimeout(b.earlyTimer);
1912
- b.earlyTimer = undefined;
1913
- }
1914
- // think-style: 清除待发的 flush 定时器, 避免 doStreamThink 在 done 后空跑。
1915
- if (a.briefBubble === b && a.thinkFlushTimer) {
1916
- clearTimeout(a.thinkFlushTimer);
1917
- a.thinkFlushTimer = undefined;
1918
- }
1919
1894
  if (a.briefBubble === b)
1920
1895
  a.briefBubble = undefined;
1896
+ // 气泡要定稿了 —— 排队中的 CoT 进度必须撤掉, 否则它会把 `链接 正文` 覆盖回进度行。
1897
+ clearCot(a);
1921
1898
  const p = (async () => {
1922
1899
  try {
1923
1900
  await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
@@ -1935,60 +1912,6 @@ export const startMirror = (deps) => {
1935
1912
  return p;
1936
1913
  };
1937
1914
  const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
1938
- // think-style tag: 紧跟在 `<think>` 之后, 不在整条消息最前面 —— `<think>🧙 …`。
1939
- const thinkTag = (target) => {
1940
- const tag = tagOfKey(target);
1941
- return tag ? `${labelFor(tag)} #${tag}` : "🧙";
1942
- };
1943
- const openThink = (target, think) => `<think>${thinkTag(target)} ${think}`;
1944
- // think-style: 把当前累积的 thinking 作为 `<think>🧙 …` 流进活着的 loading 气泡
1945
- // (finish=false, 气泡不关)。收口时再补 `</think>\n\n<final>` 并 finish。
1946
- // raw 发送: tag 已在 <think> 内, 不再经 withSessionTag 外包。
1947
- // streamPending → 首次调用时才真正开流 (fix: off-by-one race)。
1948
- // fix: 加去抖 (FLUSH_MS) + 背压 (await 上一次完成), 消除 SDK 串行队列积压。
1949
- const doStreamThink = async (a) => {
1950
- const b = a.briefBubble;
1951
- const think = a.briefThinking?.trim();
1952
- if (!b || b.done || !think)
1953
- return;
1954
- // 首条 replyStream 延迟到这里发出 (streamPending), 携带真实内容, 避免空开流竞态。
1955
- b.streamPending = false;
1956
- try {
1957
- await client.replyStream(b.frame, b.streamId, openThink(a.target, think), false);
1958
- log.debug({ sessionId: a.sessionId, streamId: b.streamId, thinkLen: think.length }, "brief: think stream ok");
1959
- }
1960
- catch (e) {
1961
- log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed");
1962
- }
1963
- };
1964
- const scheduleThinkFlush = (a) => {
1965
- if (!a.briefBubble || a.briefBubble.done || a.thinkFlushTimer || a.thinkFlushing)
1966
- return;
1967
- a.thinkFlushTimer = setTimeout(() => {
1968
- a.thinkFlushTimer = undefined;
1969
- if (a.thinkFlushing)
1970
- return; // 上一次还在飞 → 下次 pushThink 再排
1971
- a.thinkFlushing = true;
1972
- void doStreamThink(a).finally(() => { a.thinkFlushing = false; });
1973
- }, FLUSH_MS);
1974
- };
1975
- // think-style 单一入口: 一段中间过程 (reasoning / 中途文本 / 工具调用) 进 think。
1976
- // 气泡还活 → 累积进 briefThinking 并排一次去抖 flush (replyStream);
1977
- // 气泡已收口 / 无气泡 → enqueueStandalone 即时推送 (自带 3s debounce 防抖合并)。
1978
- const pushThink = (a, chunk) => {
1979
- const c = chunk.trim();
1980
- if (!c)
1981
- return;
1982
- if (a.briefBubble && !a.briefBubble.done) {
1983
- a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
1984
- scheduleThinkFlush(a);
1985
- }
1986
- else if (a.briefTurnId) {
1987
- // 气泡已收口 / 无气泡 turn: 走 standalone, 避免长 turn 期间 chat 静默。
1988
- // 原始内容入队, flushStandalone 统一做 thinkStyle 格式化 (工具行→<think>, 其余→正文)。
1989
- enqueueStandalone(a, c);
1990
- }
1991
- };
1992
1915
  // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
1993
1916
  const openBriefTurn = (a, q) => {
1994
1917
  a.briefTurnId = q.turnId;
@@ -1997,18 +1920,10 @@ export const startMirror = (deps) => {
1997
1920
  a.briefHadTool = false;
1998
1921
  a.briefConcluded = false;
1999
1922
  a.briefLastText = undefined;
2000
- a.briefThinking = undefined;
2001
- if (a.thinkFlushTimer) {
2002
- clearTimeout(a.thinkFlushTimer);
2003
- a.thinkFlushTimer = undefined;
2004
- }
2005
- a.thinkFlushing = false;
1923
+ clearCot(a); // turn 级状态 —— 进度行跟着气泡走, 不能跨 turn 残留
2006
1924
  log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
2007
1925
  };
2008
- // WeCom 侧发起一个 turn: 立刻挂 loading 气泡当 ack, 再决定是马上激活还是排队。
2009
- // 上一 turn 还在跑时绝不能强关它 —— CC 那边新消息也是排队的, 当前轮的后半段还会
2010
- // 继续落盘, 强关会把它们改挂到新 turn 上, 旧 turn 页则提前变「已完成」。
2011
- // 断点是一次性的: 只归给断点后的第一轮, 之后的轮次上下文又连续了。
1926
+ // WeCom 侧发起一个 turn。断点是一次性的: 只归给断点后的第一轮, 之后的轮次上下文又连续了。
2012
1927
  const consumeCut = (a) => {
2013
1928
  const cut = a.ctxCut;
2014
1929
  a.ctxCut = undefined;
@@ -2030,62 +1945,23 @@ export const startMirror = (deps) => {
2030
1945
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2031
1946
  const q = { turnId, bubble, isSlash };
2032
1947
  bubble.hardTimer = setTimeout(() => {
2033
- // 到点还在排队 = 前一个 turn 卡死或漏收 turn_end。强关它放行 —— 队列必须能自愈,
2034
- // 否则这条消息的产出会一直记到那个僵尸 turn 上。气泡此时已过 WeCom 更新窗口,
2035
- // 收成详情链接即可, 真正要紧的是让本 turn 激活。
2036
- if (a.briefQueue?.includes(q)) {
2037
- log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
2038
- closeBriefTurn(a);
2039
- }
2040
- // think-style: WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
2041
- // 保留当前已流进气泡的 thinking 内容作为最终快照 (用户看到的内容不变),
2042
- // 后续正文走 standalone。如果 turn 尚未结论, 标记 done 让 concludeBriefTurn
2043
- // 知道气泡已收口、走 else 分支 (sendRaw / sendStandalone)。
2044
- if (thinkStyle) {
2045
- // 活跃 turn 的 thinking 才是本气泡的内容; 排队 turn 没有 thinking, 空收即可。
2046
- const think = (a.briefBubble === bubble) ? a.briefThinking?.trim() : undefined;
2047
- const content = think ? openThink(a.target, think) : withSessionTag(a.target, " ");
2048
- // 气泡带走了已累积的 thinking; 清空 briefThinking, 后续 pushThink 走 standalone。
2049
- if (a.briefBubble === bubble)
2050
- a.briefThinking = undefined;
2051
- void finishBubble(a, bubble, content, true);
2052
- }
2053
- else {
2054
- void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
2055
- }
1948
+ // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
1949
+ void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
2056
1950
  }, HARD_TIMEOUT_MS);
2057
- // earlyTimer: 3s CLI 产出 先把 loading 气泡收成详情链接, 用户可即时点入。
2058
- // 首条 item 到达时清除 (handleBriefItem 路径)。用显式 bubble ref 而非 a.briefBubble,
2059
- // 因为排队中的 turn 还没成为活跃 turn, a.briefBubble 指向的是前一个。
2060
- // think-style: 不设 earlyTimer —— 不发链接, 气泡留着等 thinking / 答复流进来。
2061
- // codebuddy: jsonl 按完整消息落盘 (status:"completed"), 不像 Claude Code 增量写,
2062
- // 首条 item 常在 3s 之后才到达 → earlyTimer 提前收口气泡 → 结论被迫走 standalone。
2063
- // 跳过 earlyTimer, 让气泡等到真正的内容写入 (hardTimer 兜底 6min 窗口)
2064
- const skipEarly = thinkStyle || backendForPath(a.jsonlPath).name === "codebuddy";
2065
- if (!skipEarly)
2066
- bubble.earlyTimer = setTimeout(() => {
2067
- if (bubble.done)
2068
- return;
2069
- void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
2070
- }, EARLY_LINK_MS);
2071
- if (a.briefTurnId) {
2072
- (a.briefQueue ??= []).push(q);
2073
- log.info({ sessionId: a.sessionId, turnId, queued: a.briefQueue.length }, "brief: turn queued (previous still running)");
2074
- }
2075
- else {
2076
- openBriefTurn(a, q);
2077
- }
1951
+ // 新消息 = 对话边界: 立刻收掉上一 turn (气泡以详情链接收口), turn 直接
1952
+ // 激活、不排队。旧 turn CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新
1953
+ // 气泡"正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下,
1954
+ // 但远小于排队的代价: 排队 turn frame 在前一个长 turn 期间 (可达数分钟)
1955
+ // 过期, 激活后 replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。
1956
+ if (a.briefTurnId)
1957
+ closeBriefTurn(a);
1958
+ openBriefTurn(a, q);
1959
+ // ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
1960
+ // turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
1961
+ // 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
1962
+ // 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
2078
1963
  try {
2079
- // think-style: 延迟首条 replyStream 到首个内容到达 (streamPending=true),
2080
- // 避免空 `<think>` 与紧随的 pushThink 产生两次快速 replyStream 触发 WeCom
2081
- // 服务端合并/丢弃 (off-by-one)。flushStreamPending 在 pushThink/doStreamThink 首次
2082
- // 调用时开流, 保证只有一次 replyStream 携带真实内容。
2083
- if (thinkStyle) {
2084
- bubble.streamPending = true;
2085
- }
2086
- else {
2087
- await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false); // 挂住 loading 气泡, 不关闭
2088
- }
1964
+ await client.replyStream(frame, streamId, `${briefDetailLink(turnId, a.target)} …`, false);
2089
1965
  }
2090
1966
  catch (e) {
2091
1967
  log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
@@ -2108,69 +1984,34 @@ export const startMirror = (deps) => {
2108
1984
  a.briefHadTool = false;
2109
1985
  a.briefConcluded = false;
2110
1986
  a.briefLastText = undefined;
2111
- a.briefThinking = undefined;
2112
- // think-style: 不推详情链接 (CLI 侧发起的无气泡 turn 无处可流, 保持静默; 最终答复走 standalone)
2113
- if (!thinkStyle)
2114
- sendRaw(a, briefDetailLink(turnId, a.target));
1987
+ clearCot(a);
1988
+ sendRaw(a, briefDetailLink(turnId, a.target));
2115
1989
  log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
2116
1990
  };
2117
- // 本轮一旦出现工具调用 / tool_result / 非 final 文本, 就说明这不是"一句话答完"的
2118
- // 简单 turn —— 立刻把 loading 气泡收成详情链接, 用户马上能点进去看实时刷新的时间线,
2119
- // 不必等 turn 收口 (旧逻辑 URL 只在 closeBriefTurn 才写, 刷新太慢)。收链接即视作
2120
- // briefHadTool, 最终结论照常另发 standalone; closeBriefTurn 见气泡已 done 会跳过。
2121
- const earlyLinkBubble = (a) => {
2122
- if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
2123
- return;
2124
- // think-style: 不收气泡、不发链接 —— 气泡留着继续流 thinking / 最终答复。
2125
- if (thinkStyle) {
2126
- a.briefHadTool = true;
1991
+ // 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
1992
+ // 就挂在气泡里, 无需再推一次。气泡仍然不关, 等正文到来后以 `链接 正文` 覆盖收口。
1993
+ const markBriefTool = (a) => {
1994
+ if (!a.briefTurnId)
2127
1995
  return;
2128
- }
2129
1996
  a.briefHadTool = true;
2130
- void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
2131
1997
  };
2132
1998
  // 本轮结论落地, 只生效一次:
2133
- // • 无工具 结论 + 详情链接一并写进 loading 气泡 (仍是一条消息);
2134
- // • 有工具 / 无气泡 turn 气泡留给 closeBriefTurn 收详情链接, 结论另发 standalone
2135
- // 每个 turn 都必须留一个可回溯入口, 单句回答也要带链接。
1999
+ // • 气泡仍开 (ack 时的 `链接 …` 还在) 以 `链接 正文` 覆盖收口;
2000
+ // • 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) body standalone,
2001
+ // 那条 standalone 自带 `链接` 头 (withLinkedTag / ensureBriefTurn 已发过详情链接)。
2136
2002
  const concludeBriefTurn = (a, body) => {
2137
2003
  const turnId = a.briefTurnId;
2138
2004
  if (!turnId || a.briefConcluded || !body.trim())
2139
2005
  return;
2140
2006
  a.briefConcluded = true;
2141
- if (thinkStyle) {
2142
- // think-style: thinking 段以 `<think>🧙 …</think>` 收口 (tag <think> ),
2143
- // 再接正文; 无 thinking 时退回普通 tag 头正文。
2144
- // 软后端 (softTurnEnd) 的最终正文 final===undefined, 上游按"非 final 中途文本"
2145
- // 也 pushThink 进过 briefThinking —— 收口再拿它当正文 = think 与正文各一份重复。
2146
- // 收口前把 briefThinking 尾部等于 body 的那段剥掉, 保证正文只出现在 <think> 之后。
2147
- const think = stripTrailingBody(a.briefThinking?.trim(), body.trim());
2148
- if (a.briefBubble && !a.briefBubble.done) {
2149
- // 气泡还活: 整段 `<think>🧙 …</think>\n\n正文` 收进这一条 (raw, tag 已在 <think> 内)。
2150
- const out = think ? `${openThink(a.target, think)}</think>\n\n${body}` : withSessionTag(a.target, body);
2151
- void finishBriefBubble(a, out, true);
2152
- }
2153
- else {
2154
- // 无气泡 turn / 气泡已收口: briefThinking 此时只含气泡期间累积的残余
2155
- // (气泡收口后 pushThink 直接走 standalone, 不再累积)。有残余则拼成
2156
- // think+body 一条; 无残余则只发 body。
2157
- if (think) {
2158
- sendRaw(a, `${openThink(a.target, think)}</think>\n\n${body}`);
2159
- }
2160
- else {
2161
- sendStandalone(a, body);
2162
- }
2163
- }
2164
- }
2165
- else if (a.briefHadTool || !a.briefBubble) {
2166
- sendStandalone(a, body);
2007
+ if (a.briefBubble && !a.briefBubble.done) {
2008
+ void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
2167
2009
  }
2168
2010
  else {
2169
- void finishBriefBubble(a, `${body}\n\n${briefDetailLink(turnId, a.target)}`);
2011
+ sendStandalone(a, body);
2170
2012
  }
2171
- // 排队中的 turn 已经建了记录但还没跑, 不能被这把扫帚扫成「已完成」。
2172
- const keep = [turnId, ...(a.briefQueue ?? []).map((q) => q.turnId)];
2173
- recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: keep });
2013
+ // 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
2014
+ recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
2174
2015
  };
2175
2016
  /** soft=true: 收口信号来自静默期确认 (后端无终句标记), 拿本轮最后一句 text 当结论。
2176
2017
  * 硬信号路径上结论早已由 final text 落地, briefConcluded 会挡住重复推送。 */
@@ -2179,29 +2020,9 @@ export const startMirror = (deps) => {
2179
2020
  return;
2180
2021
  if (soft)
2181
2022
  concludeBriefTurn(a, a.briefLastText ?? "");
2182
- // 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
2183
- // 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
2023
+ // 气泡还开着 (正文始终没到) —— ack 时就挂出的纯详情链接收口。
2184
2024
  if (a.briefBubble && !a.briefBubble.done) {
2185
- if (thinkStyle) {
2186
- // 已结论 → 气泡已在 concludeBriefTurn 的 else 分支通过 sendRaw 发出
2187
- // think+body 整条消息,这里只需收掉气泡(避免重复下发)。
2188
- // 未结论 → doStreamThink 可能已把半成品 thinking 流进气泡 (无 </think> 闭合),
2189
- // 用闭合的 thinking 快照收口, 避免 WeCom 渲染标签未闭合的乱排版。
2190
- // 如果连 thinking 都没有 (streamPending 还在), 空收。
2191
- if (a.briefConcluded) {
2192
- void finishBriefBubble(a, withSessionTag(a.target, " "), true);
2193
- }
2194
- else {
2195
- const think = a.briefThinking?.trim();
2196
- const content = think
2197
- ? `${openThink(a.target, think)}</think>`
2198
- : withSessionTag(a.target, " ");
2199
- void finishBriefBubble(a, content, true);
2200
- }
2201
- }
2202
- else {
2203
- void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
2204
- }
2025
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
2205
2026
  }
2206
2027
  recordTurnClose(a.briefTurnId);
2207
2028
  log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
@@ -2211,16 +2032,7 @@ export const startMirror = (deps) => {
2211
2032
  a.briefIsSlash = false;
2212
2033
  a.briefConcluded = false;
2213
2034
  a.briefLastText = undefined;
2214
- a.briefThinking = undefined;
2215
- if (a.thinkFlushTimer) {
2216
- clearTimeout(a.thinkFlushTimer);
2217
- a.thinkFlushTimer = undefined;
2218
- }
2219
- a.thinkFlushing = false;
2220
- // 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
2221
- const next = a.briefQueue?.shift();
2222
- if (next)
2223
- openBriefTurn(a, next);
2035
+ clearCot(a);
2224
2036
  };
2225
2037
  // 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
2226
2038
  //
@@ -2231,19 +2043,11 @@ export const startMirror = (deps) => {
2231
2043
  // 状态的清算, Esc 之类要碰 pane 的动作留给调用方在这之后自己尽力去做。
2232
2044
  const teardownOutbound = (a, note) => {
2233
2045
  let closed = 0;
2234
- // 排队中的 turn 先清空再动当前 turn —— 反序会让 closeBriefTurn 顺手 shift
2235
- // 一个僵尸 turn 上来当活跃 turn, 白收一遍。
2236
- for (const q of a.briefQueue ?? []) {
2237
- closed++;
2238
- // think-style: 不发链接, 排队气泡收为"已取消"提示 (避免空白气泡闪过)。
2239
- void finishBubble(a, q.bubble, thinkStyle ? withSessionTag(a.target, "⏳ (queued, cancelled)") : briefDetailLink(q.turnId, a.target), thinkStyle);
2240
- recordTurnClose(q.turnId);
2241
- }
2242
- a.briefQueue = [];
2243
2046
  if (a.briefTurnId) {
2244
2047
  closed++;
2245
2048
  closeBriefTurn(a);
2246
2049
  }
2050
+ clearCot(a); // 无 turn 也要撤: 它可能正挂在一个已被 finish 掉的气泡上
2247
2051
  if (a.softEnd) {
2248
2052
  clearTimeout(a.softEnd);
2249
2053
  a.softEnd = undefined;
@@ -2255,7 +2059,7 @@ export const startMirror = (deps) => {
2255
2059
  }
2256
2060
  if (a.standaloneBuf) {
2257
2061
  clearTimeout(a.standaloneBuf.timer);
2258
- flushStandalone(a, true);
2062
+ flushStandalone(a);
2259
2063
  }
2260
2064
  if (a.liveStream && !a.liveStream.closed) {
2261
2065
  closed++;
@@ -2300,15 +2104,10 @@ export const startMirror = (deps) => {
2300
2104
  const turnId = a.briefTurnId;
2301
2105
  if (!turnId)
2302
2106
  return;
2303
- // 首条 item 到达 → 清除 earlyTimer (CLI 已有响应, 不需要超时收链接了)。
2304
- if (a.briefBubble?.earlyTimer) {
2305
- clearTimeout(a.briefBubble.earlyTimer);
2306
- a.briefBubble.earlyTimer = undefined;
2307
- }
2308
2107
  const now = Date.now();
2309
2108
  if (item.kind === "tool_use") {
2310
- a.briefHadTool = true; // 有工具 → 气泡收口写详情链接, 结论另发 standalone
2311
- earlyLinkBubble(a); // 立刻推详情链接, 不等 turn 收口 (think-style 下保持气泡开放)
2109
+ markBriefTool(a);
2110
+ updateBriefProgress(a, cotToolLabel(item.calls));
2312
2111
  for (const c of item.calls) {
2313
2112
  // 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
2314
2113
  if (c.toolUseId) {
@@ -2328,56 +2127,18 @@ export const startMirror = (deps) => {
2328
2127
  ts: now,
2329
2128
  });
2330
2129
  }
2331
- // think-style: 工具调用也算中间过程, 以 `🔧 [Name compact](url)` 摘要进 think,
2332
- // 复用 tool call detail url, 让 think 内容里的工具调用也可点开独立详情。
2333
- if (thinkStyle) {
2334
- const lines = item.calls
2335
- .map((c) => {
2336
- const compact = safeForMarkdown(renderToolInputCompact(c.input, cfg.wrc.mirror.toolUseInlineMaxChars));
2337
- const url = detailUrlFor(c.toolUseId, a.target);
2338
- return url ? `🔧 [${c.name} ${compact}](${url})` : `🔧 ${c.name} ${compact}`;
2339
- })
2340
- .join("\n");
2341
- pushThink(a, lines);
2342
- }
2343
2130
  return;
2344
2131
  }
2345
2132
  if (item.kind === "tool_result") {
2346
- earlyLinkBubble(a);
2133
+ markBriefTool(a);
2347
2134
  recordTurnItem(turnId, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
2348
- // think-style: 工具结果 (中间输出) 也进 think, 单行截断免刷屏。
2349
- if (thinkStyle)
2350
- pushThink(a, `↳ ${oneLineSummary(item.full, cfg.wrc.mirror.toolUseInlineMaxChars)}`);
2351
- return;
2352
- }
2353
- if (item.kind === "thinking") {
2354
- recordTurnItem(turnId, { t: "text", body: item.body, ts: now });
2355
- // thinking items only emitted by parser when thinkStyle=true (line 736 gate).
2356
- pushThink(a, item.body); // reasoning 进 think 流
2357
2135
  return;
2358
2136
  }
2359
2137
  if (item.kind === "text") {
2360
2138
  recordTurnItem(turnId, { t: "text", body: item.body, ts: now, final: item.final === true });
2361
- // think-style: 确知的中途输出 (final===false) 只进 think 流, 不算结论;
2362
- // 软后端的不确定态 (final===undefined) 也 pushThink — 气泡活时累积到
2363
- // briefThinking + 写 briefLastText (收口用); 气泡死时直接 standalone 发出,
2364
- // 不写 briefLastText (已发, 收口不再重复)。
2365
- if (thinkStyle && item.final === false) {
2366
- pushThink(a, item.body);
2367
- return;
2368
- }
2369
- if (thinkStyle && item.final === undefined) {
2370
- pushThink(a, item.body);
2371
- // 气泡还活时 pushThink 只累积 briefThinking, 收口需要 briefLastText 当 body;
2372
- // 气泡已死时 pushThink 走了 standalone 已发出, 不写 briefLastText 避免收口重复。
2373
- if (a.briefBubble && !a.briefBubble.done)
2374
- a.briefLastText = item.body;
2375
- return;
2376
- }
2377
- a.briefLastText = item.body; // 软收口时拿它当结论 (那条路径上没有 final 标记)
2378
- // final===false 才是"确知的中途输出"; undefined 是后端说不清, 不能据此提前收气泡。
2139
+ a.briefLastText = item.body;
2379
2140
  if (item.final === false)
2380
- earlyLinkBubble(a);
2141
+ markBriefTool(a);
2381
2142
  if (item.final === true)
2382
2143
  concludeBriefTurn(a, item.body);
2383
2144
  return;
@@ -2422,10 +2183,10 @@ export const startMirror = (deps) => {
2422
2183
  void finishBriefBubble(a, banner);
2423
2184
  else
2424
2185
  enqueueStandalone(a, banner);
2425
- // 连同排队中的 turn 一起收掉: goal 期间所有 item 走 handleGoalItem, 排队的 turn
2426
- // 永远等不到自己的产出。closeBriefTurn 会逐个放行再关闭, 每个都留下详情链接。
2427
- while (a.briefTurnId)
2428
- closeBriefTurn(a); // bubble now done → closeBriefTurn skips re-finalizing
2186
+ // 活跃 brief turn 也收掉: goal 期间所有 item 走 handleGoalItem, 它永远等不到
2187
+ // 自己的 turn_end。气泡已 done → closeBriefTurn 不会重复收口。
2188
+ if (a.briefTurnId)
2189
+ closeBriefTurn(a);
2429
2190
  a.goalActive = true;
2430
2191
  log.info({ sessionId: a.sessionId, target: a.target, condition }, "goal: entered progress mode");
2431
2192
  };
@@ -2510,6 +2271,14 @@ export const startMirror = (deps) => {
2510
2271
  clearTimeout(a.softEnd);
2511
2272
  a.softEnd = undefined;
2512
2273
  }
2274
+ // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
2275
+ // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
2276
+ // 那就变回已下线的 thinkStyle 了。
2277
+ if (item.kind === "thinking") {
2278
+ if (cfg.wrc.mirror.brief && a.briefTurnId)
2279
+ updateBriefProgress(a, item.body);
2280
+ return;
2281
+ }
2513
2282
  if (item.kind === "turn_end" && item.soft === true) {
2514
2283
  a.softEnd = setTimeout(() => fireSoftTurnEnd(a), softTurnEndMsFor(a));
2515
2284
  return;
@@ -2756,13 +2525,13 @@ export const startMirror = (deps) => {
2756
2525
  clearTimeout(a.softEnd);
2757
2526
  a.softEnd = undefined;
2758
2527
  }
2759
- while (a.briefTurnId)
2760
- closeBriefTurn(a); // 活跃 + 排队中的 turn 全部收掉
2528
+ if (a.briefTurnId)
2529
+ closeBriefTurn(a); // 活跃 turn 收掉 (队列已随对话边界策略移除)
2761
2530
  if (a.liveStream && !a.liveStream.closed)
2762
2531
  void finalizeStream(a, a.liveStream);
2763
2532
  if (a.standaloneBuf) {
2764
2533
  clearTimeout(a.standaloneBuf.timer);
2765
- flushStandalone(a, true);
2534
+ flushStandalone(a);
2766
2535
  }
2767
2536
  a.tail.stop();
2768
2537
  bySessionId.delete(a.sessionId);
@@ -2827,7 +2596,6 @@ export const startMirror = (deps) => {
2827
2596
  log: log.child({ sub: "tail", sessionId }),
2828
2597
  includeUser: cfg.wrc.mirror.includeUser,
2829
2598
  includeTools: cfg.wrc.mirror.includeTools,
2830
- thinkStyle,
2831
2599
  includeToolResults: cfg.wrc.mirror.includeToolResults,
2832
2600
  toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
2833
2601
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
@@ -3039,7 +2807,7 @@ export const startMirror = (deps) => {
3039
2807
  log.info("mirror: no persisted attachments and no pinned sessionId — waiting for /new or /mirror/attach");
3040
2808
  }
3041
2809
  // Render full tool details for a finalized turn into one-or-more markdown
3042
- // chunks (split at chunkChars boundaries).
2810
+ // chunks (split at chunkBytes boundaries).
3043
2811
  const renderToolDetails = (tools) => {
3044
2812
  const parts = [];
3045
2813
  let i = 0;
@@ -3059,7 +2827,7 @@ export const startMirror = (deps) => {
3059
2827
  parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${truncate(inputJson, 4000)}\n\`\`\`\n\n**result**\n\`\`\`\n${truncate(result, 4000)}\n\`\`\``);
3060
2828
  }
3061
2829
  const merged = parts.join("\n\n---\n\n");
3062
- return splitChunks(merged, Math.max(200, cfg.wrc.mirror.chunkChars - TAG_HEADER_BUDGET));
2830
+ return splitChunks(merged, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
3063
2831
  };
3064
2832
  const resolveToolDetail = (turnId) => {
3065
2833
  evictTurns();
@@ -3209,7 +2977,6 @@ export const startMirror = (deps) => {
3209
2977
  log: log.child({ sub: "tail", sessionId: newSessionId }),
3210
2978
  includeUser: cfg.wrc.mirror.includeUser,
3211
2979
  includeTools: cfg.wrc.mirror.includeTools,
3212
- thinkStyle,
3213
2980
  includeToolResults: cfg.wrc.mirror.includeToolResults,
3214
2981
  toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
3215
2982
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
@@ -4249,7 +4016,7 @@ export const startMirror = (deps) => {
4249
4016
  // 3) standalone 防抖里 (3s 默认) 还压着的合并文案 — 强制立刻发。
4250
4017
  if (a.standaloneBuf) {
4251
4018
  clearTimeout(a.standaloneBuf.timer);
4252
- flushStandalone(a, true);
4019
+ flushStandalone(a);
4253
4020
  }
4254
4021
  // 4) liveStream 半成品: 当前 acc 里有内容但还没 flush 出去 — 同步刷一刀,
4255
4022
  // 保留 stream 不 finalize (后续 tool_result 还要继续 append 同一气泡)。
@@ -4585,14 +4352,22 @@ export const startMirror = (deps) => {
4585
4352
  // 否则这次清空在 chat 详情里毫无痕迹, 前后两轮看着还是连续的。
4586
4353
  if (armMigration)
4587
4354
  a.ctxCut = "clear";
4588
- // Drop any prior turn's outbound deferral state — a new dispatch always
4589
- // supersedes whatever was buffered/awaiting. The old frame is dead by our
4590
- // own choice (we won't write to it anymore); the user might still later
4591
- // click an old approval card, but terminateLiveStream will find no
4592
- // matching outbound slot and no-op gracefully.
4355
+ // 新消息 = 对话边界: 上一 turn 的出站状态在此清算。softEnd 是旧 turn
4356
+ // 静默期收口判决, 残留到新 turn 会在中途开火、提前收掉新气泡/新流;
4357
+ // deferred 缓冲里是已从 tail 消费但还没下发的 item, 直接丢弃 = 静默丢失,
4358
+ // 冲成 standalone 收尾。旧 frame 就此作废; 用户之后点旧审批卡,
4359
+ // terminateLiveStream 找不到 outbound 槽位会优雅 no-op
4360
+ if (a.softEnd) {
4361
+ clearTimeout(a.softEnd);
4362
+ a.softEnd = undefined;
4363
+ }
4593
4364
  if (a.outbound) {
4594
- if (a.outbound.kind === "deferred")
4365
+ if (a.outbound.kind === "deferred") {
4595
4366
  clearTimeout(a.outbound.timer);
4367
+ const md = renderBuf(a.outbound.buf);
4368
+ if (md)
4369
+ sendStandalone(a, md);
4370
+ }
4596
4371
  a.outbound = undefined;
4597
4372
  }
4598
4373
  // Drain any pending standalone debounce so a prior turn's tail tail
@@ -4600,7 +4375,7 @@ export const startMirror = (deps) => {
4600
4375
  // race against the new stream's first content (Path B).
4601
4376
  if (a.standaloneBuf) {
4602
4377
  clearTimeout(a.standaloneBuf.timer);
4603
- flushStandalone(a, true);
4378
+ flushStandalone(a);
4604
4379
  }
4605
4380
  // /clear produces no assistant output; opening a stream would leave a
4606
4381
  // stale "…" + quoted-user bubble in WeCom. Skip the stream entirely on
@@ -4625,7 +4400,8 @@ export const startMirror = (deps) => {
4625
4400
  }
4626
4401
  }
4627
4402
  if (brief) {
4628
- // 挂 loading 气泡并起新 turn (上一 turn 还在跑则排队, 由它收口时放行)。
4403
+ // 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
4404
+ // 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
4629
4405
  // 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
4630
4406
  await startBriefTurn(a, frame, streamId, isSlash, text);
4631
4407
  }