chatccc 0.2.92 → 0.2.94

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.
package/src/session.ts CHANGED
@@ -37,7 +37,14 @@ function compressWechatDisplayText(text: string): string {
37
37
  if (lines.length <= 10) return text;
38
38
  return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
39
39
  }
40
- import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
40
+ import {
41
+ readStreamState,
42
+ writeStreamState,
43
+ createEmptyStreamState,
44
+ fixStaleStreamStates,
45
+ isFinalReplySentForTurn,
46
+ markFinalReplySent,
47
+ } from "./stream-state.ts";
41
48
  import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.ts";
42
49
  import {
43
50
  bindChatToSession,
@@ -46,7 +53,8 @@ import {
46
53
  isSessionRunning,
47
54
  activePrompts,
48
55
  displayCards,
49
- displayLoops,
56
+ unifiedDisplayLoopHandle,
57
+ setUnifiedDisplayLoopHandle,
50
58
  rebuildSessionChatsFromRegistry,
51
59
  recordLastActiveChat,
52
60
  getLastActiveChat,
@@ -58,6 +66,18 @@ import {
58
66
  consumeQueuePreservedChat,
59
67
  } from "./session-chat-binding.ts";
60
68
 
69
+ async function sendFinalReplyTextOnce(
70
+ platform: PlatformAdapter,
71
+ chatId: string,
72
+ sessionId: string,
73
+ turnCount: number,
74
+ text: string,
75
+ ): Promise<boolean> {
76
+ const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
77
+ if (sent) await markFinalReplySent(sessionId, turnCount);
78
+ return sent;
79
+ }
80
+
61
81
  // ---------------------------------------------------------------------------
62
82
  // Shared state (imported by index.ts)
63
83
  // ---------------------------------------------------------------------------
@@ -171,8 +191,7 @@ export function resetState(): void {
171
191
  chatPlatformMap.clear();
172
192
  activePrompts.clear();
173
193
  displayCards.clear();
174
- for (const stop of displayLoops.values()) stop();
175
- displayLoops.clear();
194
+ stopUnifiedDisplayLoop();
176
195
  console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
177
196
  }
178
197
 
@@ -321,7 +340,7 @@ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryDa
321
340
  * 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
322
341
  * 导致同一 sessionId 双开 generator
323
342
  * - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
324
- * - **不动** displayCards / displayLoops:正在跑的 prompt 还需要它们继续推卡片
343
+ * - **不动** displayCards:正在跑的 prompt 还需要它们继续推卡片
325
344
  * - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
326
345
  * 去重 set 还在才能避免同一 prompt 跑两遍
327
346
  *
@@ -805,34 +824,45 @@ export async function runAgentSession(
805
824
  if (displayChatId) {
806
825
  const pp = platformForChat(displayChatId);
807
826
  const display = displayCards.get(displayChatId);
808
- const oldLoopExisted = displayLoops.has(sessionId);
809
827
 
810
828
  if (display && pp) {
811
- // display loop 被 kill 前来不及终结卡片现在终结
829
+ // 统一 display loop 被 cardBusy 挡住或尚未 tick 现在终结卡片
812
830
  while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
813
- const nextSeq = display.sequence + 1;
814
- const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
815
- const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
816
- const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
817
- const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
818
- await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
819
- displayCards.delete(displayChatId);
820
-
821
- // 持久化:标记上一轮所有卡片为终态
822
- const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
823
- finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
824
831
 
825
- if (prevState.finalReply) {
826
- await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
832
+ // 竞态防护:等待期间统一 display loop 的 tick 可能也已读到同一个
833
+ // terminal state,发送了 finalReply 并删除/替换了 displayCards 条目。
834
+ // 通过引用比较检测——若统一 loop 已处理则只补持久化和头像,不重复发。
835
+ if (displayCards.get(displayChatId) !== display) {
836
+ const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
837
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
838
+ pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
839
+ } else {
840
+ const nextSeq = display.sequence + 1;
841
+ const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
842
+ const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
843
+ const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
844
+ const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
845
+ await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
846
+ // cardUpdate IO 期间统一 loop 可能也已处理此 display → 删前检查引用
847
+ const stillOursAfterUpdate = displayCards.get(displayChatId) === display;
848
+ displayCards.delete(displayChatId);
849
+
850
+ // 持久化:标记上一轮所有卡片为终态
851
+ const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
852
+ finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
853
+
854
+ if (prevState.finalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
855
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
856
+ }
857
+ pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
827
858
  }
828
- pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
829
- } else if (oldLoopExisted && pp && prevState.finalReply) {
830
- // display loop 存在但尚未创建卡片(极快轮次),至少发送 finalReply
831
- // 同时持久化终结旧 turn 卡片:旧 loop 的 mismatch 守卫可能已删 display entry
859
+ } else if (pp && prevState.finalReply && !isFinalReplySentForTurn(prevState)) {
860
+ // display 记录但上一轮有 finalReply(极快轮次),至少发送
861
+ const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
832
862
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
833
- await pp.sendText(displayChatId, prevState.finalReply).catch(() => {});
863
+ await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
834
864
  }
835
- // else: display loop 已自行终结(displayCards 无记录)→ 无需处理
865
+ // else: displayCards 无记录且无 finalReply 无需处理
836
866
  }
837
867
  }
838
868
 
@@ -841,7 +871,7 @@ export async function runAgentSession(
841
871
  await writeStreamState(initialState);
842
872
 
843
873
  // 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
844
- // 放在 ensureDisplayLoop 之前,确保卡片立即可见(不等 3s 首次 tick)。
874
+ // 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
845
875
  const displayChatIdForNew = pickDisplayChat(sessionId);
846
876
  if (displayChatIdForNew) {
847
877
  const ppNew = platformForChat(displayChatIdForNew);
@@ -858,16 +888,28 @@ export async function runAgentSession(
858
888
  cardCreatedAt: Date.now(),
859
889
  lastSentContent: "",
860
890
  streamErrorNotified: false,
891
+ sessionId,
861
892
  turnCount: nextTurnCount,
893
+ dotCount: 0,
862
894
  });
863
895
  addCardToTurn(sessionId, nextTurnCount, cardId).catch(() => {});
864
896
  }
897
+ } else if (ppNew && ppNew.kind === "wechat") {
898
+ // WeChat: 无卡片,但需要 display entry 追踪已发送内容
899
+ displayCards.set(displayChatIdForNew, {
900
+ cardId: "",
901
+ sequence: 0,
902
+ cardBusy: false,
903
+ cardCreatedAt: Date.now(),
904
+ lastSentContent: "",
905
+ streamErrorNotified: false,
906
+ sessionId,
907
+ turnCount: nextTurnCount,
908
+ dotCount: 0,
909
+ });
865
910
  }
866
911
  }
867
912
 
868
- // 启动 display loop
869
- ensureDisplayLoop(sessionId);
870
-
871
913
  // 设置最后活跃群头像为 busy
872
914
  const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
873
915
  if (activeCid) {
@@ -1019,90 +1061,83 @@ export async function runAgentSession(
1019
1061
  }
1020
1062
 
1021
1063
  // ---------------------------------------------------------------------------
1022
- // ensureDisplayLoop每个 session 一个 display 循环,读文件更新最后活跃群的卡片
1064
+ // startUnifiedDisplayLoop全局统一 display 循环,遍历 displayCards 更新卡片
1065
+ // ---------------------------------------------------------------------------
1066
+ // 替代旧的 per-session ensureDisplayLoop,消除 kill/restart 竞态条件。
1067
+ // 单一定时器遍历所有 displayCards 条目,通过条目内的 sessionId 查找 stream state。
1023
1068
  // ---------------------------------------------------------------------------
1024
1069
 
1025
1070
  const CARD_ROTATE_MS = 9 * 60 * 1000;
1026
1071
 
1027
- export function ensureDisplayLoop(sessionId: string): void {
1028
- // 杀掉旧 loop(如果存在),避免竞态:旧 loop 正在处理 terminal 分支
1029
- // (发送完成卡片、清理 display)尚未 delete 自己,新 turn 的
1030
- // runAgentSession 调用 ensureDisplayLoop 时因 guard 直接返回,
1031
- // 导致新 turn 没有 display loop → 卡片永久不更新。
1032
- const oldStop = displayLoops.get(sessionId);
1033
- if (oldStop) {
1034
- console.log(`[${ts()}] [DISPLAY] ensureDisplayLoop restarting loop for ${sessionId} (old loop existed)`);
1035
- oldStop();
1036
- displayLoops.delete(sessionId);
1037
- }
1038
-
1039
- let dotCount = 0;
1072
+ export function startUnifiedDisplayLoop(): void {
1073
+ if (unifiedDisplayLoopHandle !== null) return;
1040
1074
 
1041
1075
  const interval = setInterval(() => {
1042
1076
  void (async () => {
1043
- const state = await readStreamState(sessionId);
1044
- if (!state) return;
1045
-
1046
- // pickDisplayChat lastActiveChat 已与本 session 解绑时返回 undefined,
1047
- // 避免 /newh 等改嫁场景下旧 session 仍向已离开群推卡片。
1048
- const chatId = pickDisplayChat(sessionId);
1049
- if (!chatId) {
1050
- // 无活跃群,若 session 已结束则停止 loop
1051
- if (state.status !== "running") {
1052
- clearInterval(interval);
1053
- displayLoops.delete(sessionId);
1054
- // 兜底:lastActiveChatMap 可能因进程重启丢失,从 registry 映射恢复头像
1055
- const fallbackChat = getChatsForSession(sessionId)[0];
1056
- const fallbackPlatform = fallbackChat ? platformForChat(fallbackChat) : null;
1057
- if (fallbackChat && fallbackPlatform) {
1058
- fallbackPlatform.setChatAvatar(fallbackChat, state.tool, "idle").catch(() => {});
1077
+ for (const [chatId, display] of displayCards) {
1078
+ if (display.cardBusy) continue;
1079
+
1080
+ const sessionId = display.sessionId;
1081
+ const state = await readStreamState(sessionId);
1082
+ if (!state) {
1083
+ displayCards.delete(chatId);
1084
+ continue;
1085
+ }
1086
+
1087
+ // 交叉验证:chat 当前绑定的 session 是否仍是 display 记录的 session。
1088
+ // chat 已被切换到其他 session(如 /newh),旧 display 必须停推。
1089
+ const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
1090
+ if (currentSessionForChat && currentSessionForChat !== sessionId) {
1091
+ if (state.status !== "running") {
1092
+ displayCards.delete(chatId);
1059
1093
  }
1094
+ continue;
1060
1095
  }
1061
- return;
1062
- }
1063
1096
 
1064
- // 交叉验证:chat 当前绑定的 session 是否仍是本 display loop 的 session。
1065
- // chat 已被切换到其他 session(如 /new p2p 未解绑旧 session 的历史遗留
1066
- // 或任何未来新增的切换路径),旧 loop 必须停推,避免向已离开的 chat 推送内容。
1067
- const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
1068
- if (currentSessionForChat && currentSessionForChat !== sessionId) {
1069
- if (state.status !== "running") {
1070
- clearInterval(interval);
1071
- displayLoops.delete(sessionId);
1097
+ // 验证 chat 仍是该 session 的最后活跃群
1098
+ const lastActive = getLastActiveChat(sessionId);
1099
+ if (lastActive !== chatId) {
1100
+ if (state.status !== "running") {
1101
+ displayCards.delete(chatId);
1102
+ }
1103
+ continue;
1072
1104
  }
1073
- return;
1074
- }
1075
1105
 
1076
- const isTerminal = state.status !== "running";
1077
-
1078
- try {
1079
- const p = platformForChat(chatId);
1080
- if (!p) return;
1081
- const display = displayCards.get(chatId);
1082
-
1083
- const isWechat = p.kind === "wechat";
1084
-
1085
- if (isTerminal) {
1086
- if (isWechat) {
1087
- // WeChat: 没有卡片需要终结,用 delta 逻辑发剩余内容,避免重发已推送的部分
1088
- // 分开追踪 accumulatedContent 和 finalReply,而非拼接后对比
1089
- const prevAccLen = display?.lastSentAccLen ?? 0;
1090
- const prevFinalReply = display?.lastSentFinalReply ?? "";
1091
- const accDelta = state.accumulatedContent.slice(prevAccLen);
1092
- let replyDelta: string;
1093
- if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1094
- replyDelta = state.finalReply.slice(prevFinalReply.length);
1106
+ const isTerminal = state.status !== "running";
1107
+
1108
+ try {
1109
+ const p = platformForChat(chatId);
1110
+ if (!p) continue;
1111
+
1112
+ const isWechat = p.kind === "wechat";
1113
+
1114
+ if (isTerminal) {
1115
+ if (isWechat) {
1116
+ const prevAccLen = display.lastSentAccLen ?? 0;
1117
+ const prevFinalReply = display.lastSentFinalReply ?? "";
1118
+ const accDelta = state.accumulatedContent.slice(prevAccLen);
1119
+ let replyDelta: string;
1120
+ if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1121
+ replyDelta = state.finalReply.slice(prevFinalReply.length);
1122
+ } else {
1123
+ replyDelta = state.finalReply;
1124
+ }
1125
+ const remaining = (accDelta + replyDelta).trim();
1126
+
1127
+ // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1128
+ // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1129
+ // 写入的,finalReply 滞后于内存态。跳过发送,等 finally 落盘后
1130
+ // 下一次 tick 再处理,避免发送过期内容或与后续发送重复。
1131
+ if (activePrompts.has(sessionId)) continue;
1132
+
1133
+ const tail = "━━━ 回答结束 ━━━";
1134
+ const finalMsg = remaining ? remaining + "\n" + tail : tail;
1135
+ if (!isFinalReplySentForTurn(state)) {
1136
+ await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, finalMsg);
1137
+ }
1138
+ displayCards.delete(chatId);
1095
1139
  } else {
1096
- replyDelta = state.finalReply;
1097
- }
1098
- const remaining = (accDelta + replyDelta).trim();
1099
- const tail = "━━━ 回答结束 ━━━";
1100
- const finalMsg = remaining ? remaining + "\n" + tail : tail;
1101
- await p.sendText(chatId, finalMsg).catch(() => {});
1102
- if (display) displayCards.delete(chatId);
1103
- } else {
1104
- // 发送最终结果(卡片平台)
1105
- if (display) {
1140
+ // 发送最终结果(卡片平台)
1106
1141
  while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
1107
1142
  const nextSeq = display.sequence + 1;
1108
1143
  const headerTitle = state.status === "stopped" ? "已停止" : "完成";
@@ -1110,102 +1145,71 @@ export function ensureDisplayLoop(sessionId: string): void {
1110
1145
  const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
1111
1146
  const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1112
1147
  await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
1148
+
1149
+ // 若 session 仍在 activePrompts 中,说明 runAgentSession 的 finally
1150
+ // 还没执行,当前 stream state 可能是 stopSession fire-and-forget
1151
+ // 写入的,finalReply 滞后于内存态。卡片已更新为终态外观,但不发送
1152
+ // 文本、不删除 display 条目,留给 finally 落盘后的下一次 tick 处理。
1153
+ if (activePrompts.has(sessionId)) {
1154
+ display.lastSentAccLen = state.accumulatedContent.length;
1155
+ display.lastSentFinalReply = state.finalReply;
1156
+ continue;
1157
+ }
1158
+
1113
1159
  const finalSt = state.status === "stopped" ? "stopped" : "done";
1114
1160
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
1115
1161
  displayCards.delete(chatId);
1116
- }
1117
- if (state.finalReply) {
1118
- await p.sendText(chatId, state.finalReply).catch(() => {});
1119
- } else if (!display && state.accumulatedContent.trim()) {
1120
- const short = truncateContent(state.accumulatedContent, 30, 4000);
1121
- await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
1122
- }
1123
- }
1124
- p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
1125
- } else {
1126
- // running: 创建或更新展示
1127
- if (isWechat) {
1128
- // WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
1129
- if (!display) {
1130
- displayCards.set(chatId, {
1131
- cardId: "",
1132
- sequence: 0,
1133
- cardBusy: false,
1134
- cardCreatedAt: Date.now(),
1135
- lastSentContent: "",
1136
- streamErrorNotified: false,
1137
- });
1138
- }
1139
- const d = displayCards.get(chatId);
1140
- if (!d || d.cardBusy) return;
1141
-
1142
- // 分开追踪 accumulatedContent 和 finalReply 的已发送位置。
1143
- // 如果只用 rawFull.startsWith(prevRaw),当新的 tool_use/tool_result
1144
- // 插入到 accumulatedContent 中间时,rawFull 不再以 prevRaw 开头,
1145
- // 会回退到发送完整内容 → 产生大量重复。
1146
- const prevAccLen = d.lastSentAccLen ?? 0;
1147
- const prevFinalReply = d.lastSentFinalReply ?? "";
1148
- const accDelta = state.accumulatedContent.slice(prevAccLen);
1149
- let replyDelta: string;
1150
- if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1151
- replyDelta = state.finalReply.slice(prevFinalReply.length);
1152
- } else {
1153
- replyDelta = state.finalReply;
1154
- }
1155
- const delta = (accDelta + replyDelta).trim();
1156
- if (!delta) return;
1157
-
1158
- d.cardBusy = true;
1159
- try {
1160
- const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
1161
- if (ok) {
1162
- d.lastSentAccLen = state.accumulatedContent.length;
1163
- d.lastSentFinalReply = state.finalReply;
1164
- d.lastSentContent = delta;
1165
- } else {
1166
- // 发送失败(限流等),不更新光标,下次合并重试
1167
- return;
1168
- }
1169
- } catch (err) {
1170
- console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${(err as Error).message}`);
1171
- if (!d.streamErrorNotified) {
1172
- d.streamErrorNotified = true;
1173
- p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => {});
1162
+ if (state.finalReply) {
1163
+ if (!isFinalReplySentForTurn(state)) {
1164
+ await sendFinalReplyTextOnce(p, chatId, sessionId, state.turnCount, state.finalReply);
1165
+ }
1166
+ } else if (state.accumulatedContent.trim()) {
1167
+ const short = truncateContent(state.accumulatedContent, 30, 4000);
1168
+ await p.sendText(chatId, `[生成过程]\n${short}`).catch(() => {});
1174
1169
  }
1175
- } finally {
1176
- d.cardBusy = false;
1177
1170
  }
1171
+ p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
1172
+ console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
1178
1173
  } else {
1179
- // 非 WeChat: 卡片流程
1180
- if (!display) {
1181
- // 兜底:runAgentSession 应已创建卡片,若没有则补建
1182
- const cardId = await p.cardCreate(buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
1183
- if (cardId) {
1184
- await p.cardSend(chatId, cardId).catch(() => null);
1185
- displayCards.set(chatId, {
1186
- cardId,
1187
- sequence: 1,
1188
- cardBusy: false,
1189
- cardCreatedAt: Date.now(),
1190
- lastSentContent: "",
1191
- streamErrorNotified: false,
1192
- turnCount: state.turnCount,
1193
- });
1194
- addCardToTurn(sessionId, state.turnCount, cardId).catch(() => {});
1174
+ // running: 创建或更新展示
1175
+ if (isWechat) {
1176
+ // WeChat: 不使用卡片,基于 agent 真实 delta 推送 raw content
1177
+ const prevAccLen = display.lastSentAccLen ?? 0;
1178
+ const prevFinalReply = display.lastSentFinalReply ?? "";
1179
+ const accDelta = state.accumulatedContent.slice(prevAccLen);
1180
+ let replyDelta: string;
1181
+ if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
1182
+ replyDelta = state.finalReply.slice(prevFinalReply.length);
1183
+ } else {
1184
+ replyDelta = state.finalReply;
1195
1185
  }
1196
- } else {
1197
- if (display.cardBusy) return;
1186
+ const delta = (accDelta + replyDelta).trim();
1187
+ if (!delta) continue;
1198
1188
 
1199
- // 守卫:display 归属的 turn 与当前 stream state 不一致。
1200
- // 正常流程 runAgentSession 会终结旧 turn 并建新卡,
1201
- // 此处仅兜底处理异常情况(如进程重启后 display 残留)。
1189
+ display.cardBusy = true;
1190
+ try {
1191
+ const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
1192
+ if (ok) {
1193
+ display.lastSentAccLen = state.accumulatedContent.length;
1194
+ display.lastSentFinalReply = state.finalReply;
1195
+ display.lastSentContent = delta;
1196
+ }
1197
+ } catch (err) {
1198
+ console.error(`[${ts()}] WeChat sendText error: chatId=${chatId} ${(err as Error).message}`);
1199
+ if (!display.streamErrorNotified) {
1200
+ display.streamErrorNotified = true;
1201
+ p.sendText(chatId, "文本发送失败,请稍后查看结果。").catch(() => {});
1202
+ }
1203
+ } finally {
1204
+ display.cardBusy = false;
1205
+ }
1206
+ } else {
1207
+ // 非 WeChat: 卡片流程
1202
1208
  if (display.turnCount !== state.turnCount) {
1203
1209
  console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
1204
- // 兜底:turn mismatch 意味着旧 turn 已结束但卡片未在飞书端终结,
1205
- // 持久化标记为 done,避免 turn-cards.json 残留 "active"
1206
1210
  finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => {});
1207
1211
  displayCards.delete(chatId);
1208
- return;
1212
+ continue;
1209
1213
  }
1210
1214
 
1211
1215
  // 卡片轮转
@@ -1214,7 +1218,7 @@ export function ensureDisplayLoop(sessionId: string): void {
1214
1218
  try {
1215
1219
  const oldSeqBase = display.sequence;
1216
1220
  const oldContent = state.accumulatedContent + state.finalReply;
1217
- const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "完成" });
1221
+ const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
1218
1222
  await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).catch(() => {});
1219
1223
  markCardDone(sessionId, display.turnCount, display.cardId).catch(() => {});
1220
1224
  const newCardId = await p.cardCreate(buildProgressCard(
@@ -1227,8 +1231,6 @@ export function ensureDisplayLoop(sessionId: string): void {
1227
1231
  display.cardId = newCardId;
1228
1232
  display.sequence = 1;
1229
1233
  display.cardCreatedAt = Date.now();
1230
- // 记录基线:分开追踪 accumulatedContent 长度和 finalReply,
1231
- // 避免 tool 内容中间插入时前缀匹配失败 → 回退发完整内容
1232
1234
  display.rotationAccLen = state.accumulatedContent.length;
1233
1235
  display.rotationFinalReply = state.finalReply;
1234
1236
  display.lastSentContent = "";
@@ -1239,11 +1241,10 @@ export function ensureDisplayLoop(sessionId: string): void {
1239
1241
  } finally {
1240
1242
  display.cardBusy = false;
1241
1243
  }
1242
- return;
1244
+ continue;
1243
1245
  }
1244
1246
 
1245
- // 轮转后:分开追踪 accumulatedContent 和 finalReply 增量,
1246
- // 避免 tool 内容插入中间时前缀匹配失败
1247
+ // 轮转后:分开追踪 accumulatedContent 和 finalReply 增量
1247
1248
  if (display.rotationAccLen !== undefined) {
1248
1249
  const accDelta = state.accumulatedContent.slice(display.rotationAccLen);
1249
1250
  const rotReply = display.rotationFinalReply ?? "";
@@ -1255,10 +1256,9 @@ export function ensureDisplayLoop(sessionId: string): void {
1255
1256
  }
1256
1257
  const delta = (accDelta + replyDelta).trim();
1257
1258
 
1258
- // 无论有无新内容,都追加点点点动画,避免轮转后卡片静止
1259
- dotCount = (dotCount % 9) + 1;
1260
- const displayContent = (delta || "") + "\n" + "。" .repeat(dotCount);
1261
- if (displayContent === display.lastSentContent) return;
1259
+ display.dotCount = (display.dotCount % 9) + 1;
1260
+ const displayContent = (delta || "") + "\n" + "。" .repeat(display.dotCount);
1261
+ if (displayContent === display.lastSentContent) continue;
1262
1262
 
1263
1263
  display.lastSentContent = displayContent;
1264
1264
  const deltaCard = buildProgressCard(truncateContent(displayContent) || "处理中...", { showStop: true, headerTitle: "生成中..." });
@@ -1276,12 +1276,12 @@ export function ensureDisplayLoop(sessionId: string): void {
1276
1276
  } finally {
1277
1277
  display.cardBusy = false;
1278
1278
  }
1279
- return;
1279
+ continue;
1280
1280
  }
1281
1281
 
1282
- dotCount = (dotCount % 9) + 1;
1283
- const fullContent = state.accumulatedContent + state.finalReply + "\n" + "。".repeat(dotCount);
1284
- if (fullContent === display.lastSentContent) return;
1282
+ display.dotCount = (display.dotCount % 9) + 1;
1283
+ const fullContent = state.accumulatedContent + state.finalReply + "\n" + "。".repeat(display.dotCount);
1284
+ if (fullContent === display.lastSentContent) continue;
1285
1285
 
1286
1286
  display.lastSentContent = fullContent;
1287
1287
  const cardContent = truncateContent(fullContent);
@@ -1302,23 +1302,26 @@ export function ensureDisplayLoop(sessionId: string): void {
1302
1302
  }
1303
1303
  }
1304
1304
  }
1305
+ } catch (err) {
1306
+ console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
1305
1307
  }
1306
- } catch (err) {
1307
- console.error(`[${ts()}] Display loop error for ${chatId}: ${(err as Error).message}`);
1308
- }
1309
-
1310
- if (isTerminal) {
1311
- console.log(`[${ts()}] [DISPLAY] loop stopping for ${sessionId} (terminal state: ${state.status})`);
1312
- clearInterval(interval);
1313
- displayLoops.delete(sessionId);
1314
1308
  }
1315
1309
  })().catch((err: unknown) => {
1316
1310
  const e = err instanceof Error ? err : new Error(String(err));
1317
- console.error(`[${ts()}] Display loop uncaught for ${sessionId}: ${e.message}`);
1311
+ console.error(`[${ts()}] Unified display loop uncaught: ${e.message}`);
1318
1312
  });
1319
1313
  }, 3000);
1320
1314
 
1321
- displayLoops.set(sessionId, () => clearInterval(interval));
1315
+ setUnifiedDisplayLoopHandle(interval);
1316
+ console.log(`[${ts()}] [DISPLAY] Unified display loop started`);
1317
+ }
1318
+
1319
+ export function stopUnifiedDisplayLoop(): void {
1320
+ if (unifiedDisplayLoopHandle !== null) {
1321
+ clearInterval(unifiedDisplayLoopHandle);
1322
+ setUnifiedDisplayLoopHandle(null);
1323
+ console.log(`[${ts()}] [DISPLAY] Unified display loop stopped`);
1324
+ }
1322
1325
  }
1323
1326
 
1324
1327
  // ---------------------------------------------------------------------------
@@ -14,6 +14,9 @@ export interface StreamState {
14
14
  status: "running" | "done" | "stopped" | "error";
15
15
  accumulatedContent: string;
16
16
  finalReply: string;
17
+ /** The turn whose terminal text reply has already been delivered to IM. */
18
+ finalReplySentTurn?: number;
19
+ finalReplySentAt?: number;
17
20
  chunkCount: number;
18
21
  turnCount: number;
19
22
  contextTokens: number;
@@ -97,6 +100,22 @@ export async function writeStreamState(state: StreamState): Promise<void> {
97
100
  }
98
101
  }
99
102
 
103
+ export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
104
+ return state.finalReplySentTurn === state.turnCount;
105
+ }
106
+
107
+ export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
108
+ const state = await readStreamState(sessionId);
109
+ if (!state) return;
110
+ if (state.turnCount !== turnCount) return;
111
+ if (state.status === "running") return;
112
+
113
+ state.finalReplySentTurn = turnCount;
114
+ state.finalReplySentAt = sentAt;
115
+ state.updatedAt = Date.now();
116
+ await writeStreamState(state);
117
+ }
118
+
100
119
  export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
101
120
  return {
102
121
  sessionId,
@@ -138,4 +157,4 @@ export async function fixStaleStreamStates(): Promise<void> {
138
157
  } catch (err) {
139
158
  console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
140
159
  }
141
- }
160
+ }