chatccc 0.2.210 → 0.2.211

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.210",
3
+ "version": "0.2.211",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -73,6 +73,7 @@ describe("createCccAdapter", () => {
73
73
  expect(messages).toEqual([
74
74
  { type: "assistant", blocks: [{ type: "text", text: "hello" }] },
75
75
  { type: "assistant", blocks: [{ type: "text", text: " world" }] },
76
+ { type: "assistant", blocks: [], isFinalResponse: true },
76
77
  ]);
77
78
  expect(streamTextMock).toHaveBeenCalledWith(expect.objectContaining({
78
79
  model: { modelId: "deepseek-v4-flash" },
@@ -109,6 +110,7 @@ describe("createCccAdapter", () => {
109
110
  type: "assistant",
110
111
  blocks: [{ type: "tool_result", tool_use_id: "call-1", content: { content: "hello" }, is_error: false }],
111
112
  },
113
+ { type: "assistant", blocks: [], isFinalResponse: true },
112
114
  ]);
113
115
  });
114
116
  });
@@ -269,10 +269,17 @@ describe("normalizeSdkMessage", () => {
269
269
  expect(result!.blocks[0]).toEqual({ type: "text", text: "still here" });
270
270
  });
271
271
 
272
- it("returns null for non-assistant/non-user/non-system messages", () => {
272
+ it("marks a successful result event as an authoritative final response", () => {
273
273
  expect(
274
274
  normalizeSdkMessage({ type: "result", subtype: "success" }),
275
- ).toBeNull();
275
+ ).toEqual({
276
+ type: "assistant",
277
+ blocks: [],
278
+ isFinalResponse: true,
279
+ });
280
+ });
281
+
282
+ it("returns null for other non-assistant/non-user/non-system messages", () => {
276
283
  expect(
277
284
  normalizeSdkMessage({ type: "stream_event" }),
278
285
  ).toBeNull();
@@ -61,10 +61,11 @@ describe("normalizeCodexMessage", () => {
61
61
  type: "item.completed",
62
62
  item: { id: "item_0", type: "agent_message", text: "hello" },
63
63
  });
64
- expect(result).not.toBeNull();
65
- expect(result!.type).toBe("assistant");
66
- expect(result!.blocks).toEqual([{ type: "text", text: "hello" }]);
67
- });
64
+ expect(result).not.toBeNull();
65
+ expect(result!.type).toBe("assistant");
66
+ expect(result!.blocks).toEqual([{ type: "text", text: "hello" }]);
67
+ expect(result!.isFinalResponse).toBe(true);
68
+ });
68
69
 
69
70
  it("normalizes command_execution start into tool_use block", () => {
70
71
  const result = normalizeCodexMessage({
@@ -139,6 +139,7 @@ describe("Codex raw stream logs", () => {
139
139
  expect(events).toContainEqual({
140
140
  type: "assistant",
141
141
  blocks: [{ type: "text", text: "hello" }],
142
+ isFinalResponse: true,
142
143
  });
143
144
  });
144
145
 
@@ -490,10 +490,11 @@ describe("normalizeCursorMessage - result 消息(官方权威最终文本)",
490
490
  });
491
491
  expect(result).not.toBeNull();
492
492
  expect(result!.type).toBe("assistant");
493
- expect(result!.blocks).toEqual([
494
- { type: "text_final", text: "权威最终文本" },
495
- ]);
496
- });
493
+ expect(result!.blocks).toEqual([
494
+ { type: "text_final", text: "权威最终文本" },
495
+ ]);
496
+ expect(result!.isFinalResponse).toBe(true);
497
+ });
497
498
 
498
499
  it("result 消息没有 result 字段时返回 null(无可用文本)", () => {
499
500
  expect(
@@ -87,7 +87,12 @@ import {
87
87
  resetState,
88
88
  sessionInfoMap,
89
89
  } from "../session.ts";
90
- import { activePrompts, dequeueMessage, resetBindingState } from "../session-chat-binding.ts";
90
+ import {
91
+ activePrompts,
92
+ dequeueMessage,
93
+ getChatsForSession,
94
+ resetBindingState,
95
+ } from "../session-chat-binding.ts";
91
96
  import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
92
97
  import { config } from "../config.ts";
93
98
 
@@ -802,6 +807,7 @@ describe("handleCommand WeChat processing ack", () => {
802
807
  expect(platform.sendRawCard).toHaveBeenCalled();
803
808
  const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
804
809
  expect(JSON.stringify(card)).toContain("xhigh");
810
+ expect(getChatsForSession("sid-codex-effort")).toContain("codex-chat");
805
811
  });
806
812
 
807
813
  it("rejects /effort in Cursor sessions", async () => {
@@ -123,6 +123,8 @@ import {
123
123
  _setResponseStallCheckIntervalForTest,
124
124
  _resetResponseStallCheckIntervalForTest,
125
125
  setSessionEffortOverride,
126
+ RESPONSE_STALL_RECOVERY_PROMPT,
127
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
126
128
  } from "../session.ts";
127
129
  import {
128
130
  activePrompts,
@@ -670,7 +672,7 @@ describe("runAgentSession process monitor", () => {
670
672
 
671
673
  describe("runAgentSession response stall watchdog", () => {
672
674
  let tempDir = "";
673
- const recoveryPrompt = "完成了吗?如果没完成继续";
675
+ const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
674
676
 
675
677
  beforeEach(async () => {
676
678
  vi.useFakeTimers();
@@ -768,6 +770,155 @@ describe("runAgentSession response stall watchdog", () => {
768
770
  });
769
771
  });
770
772
 
773
+ it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
774
+ vi.setSystemTime(0);
775
+ _setResponseStallTimeoutForTest(100);
776
+ _setResponseStallCheckIntervalForTest(10);
777
+ _setProcessAliveForTest(() => true);
778
+
779
+ const platform = mockPlatform("feishu");
780
+ setSessionPlatform(platform);
781
+
782
+ const receivedPrompts: string[] = [];
783
+ const adapter: ToolAdapter = {
784
+ displayName: "Any Agent",
785
+ sessionDescPrefix: "Agent Session:",
786
+ createSession: async () => ({ sessionId: "sid-binding-recovery" }),
787
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
788
+ closeSession: async () => {},
789
+ prompt: async function* (
790
+ _sid: string,
791
+ text: string,
792
+ _cwd: string,
793
+ signal?: AbortSignal,
794
+ options?: ToolPromptOptions,
795
+ ) {
796
+ receivedPrompts.push(text);
797
+ options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
798
+ if (receivedPrompts.length === 1) {
799
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
800
+ await new Promise<void>((resolve) => {
801
+ if (signal?.aborted) {
802
+ resolve();
803
+ return;
804
+ }
805
+ signal?.addEventListener("abort", () => resolve(), { once: true });
806
+ });
807
+ return;
808
+ }
809
+ yield {
810
+ type: "assistant",
811
+ blocks: [{ type: "text", text: "recovery completed" }],
812
+ isFinalResponse: true,
813
+ };
814
+ },
815
+ };
816
+ _setAdapterForToolForTest("claude", adapter);
817
+
818
+ const firstRun = runAgentSession(
819
+ "sid-binding-recovery",
820
+ "first prompt",
821
+ platform,
822
+ "chat-binding-recovery",
823
+ 0,
824
+ "claude",
825
+ );
826
+
827
+ await vi.waitFor(() => {
828
+ expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
829
+ });
830
+ const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
831
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
832
+ await firstRun;
833
+ await vi.advanceTimersByTimeAsync(200);
834
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
835
+ await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
836
+
837
+ expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
838
+ expect(platform.cardCreate).toHaveBeenCalledTimes(2);
839
+ expect(platform.cardSend).toHaveBeenCalledTimes(2);
840
+ expect(platform.sendText).toHaveBeenCalledWith(
841
+ "chat-binding-recovery",
842
+ expect.stringContaining(recoveryPrompt),
843
+ );
844
+
845
+ const registry = JSON.parse(
846
+ await readFile(join(tempDir, "session-registry.json"), "utf8"),
847
+ ) as Record<string, { running?: boolean }>;
848
+ expect(registry["chat-binding-recovery"]?.running).toBe(false);
849
+ });
850
+
851
+ it("treats an authoritative final response that races timeout cleanup as completed", async () => {
852
+ vi.setSystemTime(0);
853
+ _setResponseStallTimeoutForTest(100);
854
+ _setResponseStallCheckIntervalForTest(10);
855
+ _setProcessAliveForTest(() => true);
856
+
857
+ const platform = mockPlatform("feishu");
858
+ setSessionPlatform(platform);
859
+ bindChatToSession("sid-final-race", "chat-final-race");
860
+ recordLastActiveChat("sid-final-race", "chat-final-race");
861
+
862
+ const receivedPrompts: string[] = [];
863
+ const adapter: ToolAdapter = {
864
+ displayName: "Any Agent",
865
+ sessionDescPrefix: "Agent Session:",
866
+ createSession: async () => ({ sessionId: "sid-final-race" }),
867
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
868
+ closeSession: async () => {},
869
+ prompt: async function* (
870
+ _sid: string,
871
+ text: string,
872
+ _cwd: string,
873
+ signal?: AbortSignal,
874
+ options?: ToolPromptOptions,
875
+ ) {
876
+ receivedPrompts.push(text);
877
+ options?.onProcessStart?.({ pid: 5151 });
878
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
879
+ await new Promise<void>((resolve) => {
880
+ if (signal?.aborted) {
881
+ resolve();
882
+ return;
883
+ }
884
+ signal?.addEventListener("abort", () => resolve(), { once: true });
885
+ });
886
+ yield {
887
+ type: "assistant",
888
+ blocks: [{ type: "text", text: "completed at the timeout boundary" }],
889
+ isFinalResponse: true,
890
+ };
891
+ },
892
+ };
893
+ _setAdapterForToolForTest("claude", adapter);
894
+
895
+ const run = runAgentSession(
896
+ "sid-final-race",
897
+ "prompt",
898
+ platform,
899
+ "chat-final-race",
900
+ 0,
901
+ "claude",
902
+ );
903
+ await vi.waitFor(() => {
904
+ expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
905
+ });
906
+ const progress = activePrompts.get("sid-final-race")!.responseProgress!;
907
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
908
+ await run;
909
+ await vi.advanceTimersByTimeAsync(1_000);
910
+
911
+ expect(receivedPrompts).toHaveLength(1);
912
+ expect(mockStreamStates.get("sid-final-race")).toMatchObject({
913
+ status: "done",
914
+ finalReply: "completed at the timeout boundary",
915
+ });
916
+ expect(platform.sendText).not.toHaveBeenCalledWith(
917
+ "chat-final-race",
918
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
919
+ );
920
+ });
921
+
771
922
  it("runs the reserved recovery prompt before an already queued user message", async () => {
772
923
  vi.setSystemTime(0);
773
924
  _setResponseStallTimeoutForTest(100);
@@ -842,7 +993,7 @@ describe("runAgentSession response stall watchdog", () => {
842
993
  expect(isSessionRunning("sid-recovery-priority")).toBe(true);
843
994
  expect(platform.sendText).toHaveBeenCalledWith(
844
995
  "chat-recovery-priority",
845
- "检测到会话停滞,正在自动确认并继续。",
996
+ expect.stringContaining(recoveryPrompt),
846
997
  );
847
998
 
848
999
  await vi.advanceTimersByTimeAsync(200);
@@ -87,10 +87,17 @@ export type UnifiedBlock =
87
87
  // UnifiedStreamMessage — 一次 SDK/CLI 事件对应的归一化消息
88
88
  // ---------------------------------------------------------------------------
89
89
 
90
- export interface UnifiedStreamMessage {
91
- type: "assistant" | "user" | "system";
92
- blocks: UnifiedBlock[];
93
- }
90
+ export interface UnifiedStreamMessage {
91
+ type: "assistant" | "user" | "system";
92
+ blocks: UnifiedBlock[];
93
+ /**
94
+ * 适配器确认本事件代表一条完整、权威的最终回复,而不是流式文本片段。
95
+ *
96
+ * response-stall 终止与最终事件可能在同一事件循环边界竞态;只有该标记
97
+ * 为 true 时,上层才允许把已经触发的超时改判为正常完成。
98
+ */
99
+ isFinalResponse?: boolean;
100
+ }
94
101
 
95
102
  // ---------------------------------------------------------------------------
96
103
  // CreateSessionResult
@@ -194,4 +201,4 @@ export function parseUserCommand(userText: string): UserCommand {
194
201
  if (original.startsWith("/plan")) return { original, mode: "plan" };
195
202
  if (original.startsWith("/ask")) return { original, mode: "ask" };
196
203
  return { original, mode: null };
197
- }
204
+ }
@@ -96,6 +96,12 @@ export function createCccAdapter(options: CccAdapterOptions = {}): ToolAdapter {
96
96
  is_error: event.is_error,
97
97
  }],
98
98
  };
99
+ } else if (event.type === "done" && !signal?.aborted) {
100
+ yield {
101
+ type: "assistant",
102
+ blocks: [],
103
+ isFinalResponse: true,
104
+ };
99
105
  } else if (event.type === "error") {
100
106
  throw new Error(event.message);
101
107
  }
@@ -57,6 +57,7 @@ interface SdkContentBlock {
57
57
  interface SdkMessageLike {
58
58
  type?: string;
59
59
  subtype?: string;
60
+ result?: string;
60
61
  message?: { content?: SdkContentBlock[] };
61
62
  compact_metadata?: {
62
63
  trigger?: "manual" | "auto";
@@ -202,6 +203,16 @@ function logMcpConfig(): void {
202
203
  }
203
204
 
204
205
  export function normalizeSdkMessage(msg: SdkMessageLike): UnifiedStreamMessage | null {
206
+ // SDK result/success 是 Claude 对本轮完整结束的权威确认。文本已经由之前的
207
+ // assistant 消息累计,因此这里只发送终态信号,避免重复追加 result 文本。
208
+ if (msg.type === "result" && msg.subtype === "success") {
209
+ return {
210
+ type: "assistant",
211
+ blocks: [],
212
+ isFinalResponse: true,
213
+ };
214
+ }
215
+
205
216
  if (
206
217
  (msg.type === "assistant" || msg.type === "user") &&
207
218
  msg.message?.content
@@ -139,6 +139,7 @@ export function normalizeCodexMessage(
139
139
  return {
140
140
  type: "assistant",
141
141
  blocks: [{ type: "text", text: msg.item.text }],
142
+ isFinalResponse: true,
142
143
  };
143
144
  }
144
145
 
@@ -353,6 +353,7 @@ export function normalizeCursorMessage(
353
353
  return {
354
354
  type: "assistant",
355
355
  blocks: [{ type: "text_final", text: msg.result }],
356
+ isFinalResponse: true,
356
357
  };
357
358
  }
358
359
 
@@ -1143,11 +1143,47 @@ export async function handleCommand(
1143
1143
  chatInfo = await platform.getChatInfo(chatId);
1144
1144
  description = chatInfo.description;
1145
1145
  const sessionInfo = platform.extractSessionInfo(description);
1146
- if (sessionInfo) {
1147
- sessionId = sessionInfo.sessionId;
1148
- descriptionTool = sessionInfo.tool;
1149
- toolLabel = toolDisplayName(descriptionTool);
1150
- }
1146
+ if (sessionInfo) {
1147
+ sessionId = sessionInfo.sessionId;
1148
+ descriptionTool = sessionInfo.tool;
1149
+ toolLabel = toolDisplayName(descriptionTool);
1150
+
1151
+ // 群描述是群聊会话路由的权威来源。历史群可能早于 registry 创建,
1152
+ // 或在冷启动时没有被重建进内存映射;若只解析 sessionId 而不补绑定,
1153
+ // prompt 虽能启动,却找不到生成卡片目标,收尾也无法清除 running。
1154
+ const registry = await loadSessionRegistryForBinding();
1155
+ const record = registry[chatId];
1156
+ if (record?.sessionId && record.sessionId !== sessionId) {
1157
+ unbindChatFromSession(record.sessionId, chatId);
1158
+ }
1159
+ bindChatToSession(sessionId, chatId);
1160
+
1161
+ const memoryInfo = sessionInfoMap.get(chatId);
1162
+ if (!memoryInfo || memoryInfo.sessionId !== sessionId) {
1163
+ sessionInfoMap.set(chatId, {
1164
+ sessionId,
1165
+ tool: descriptionTool,
1166
+ turnCount: record?.sessionId === sessionId ? record.turnCount : 0,
1167
+ lastContextTokens:
1168
+ record?.sessionId === sessionId ? record.lastContextTokens : 0,
1169
+ startTime:
1170
+ record?.sessionId === sessionId
1171
+ ? record.startTime
1172
+ : Date.now(),
1173
+ });
1174
+ }
1175
+
1176
+ // 同步自愈持久化记录,使下一次重启可以直接重建绑定。running 取实际
1177
+ // 内存状态,顺便修复旧故障遗留的 stale running=true。
1178
+ await recordSessionRegistry({
1179
+ chatId,
1180
+ sessionId,
1181
+ tool: descriptionTool,
1182
+ chatType,
1183
+ chatName: chatInfo.name,
1184
+ running: isSessionRunning(sessionId),
1185
+ });
1186
+ }
1151
1187
  } catch (err) {
1152
1188
  logTrace(tid, "BRANCH", {
1153
1189
  reason: "get_chat_info_failed",
@@ -123,6 +123,8 @@ export interface ActivePrompt {
123
123
  resourceStuck?: boolean;
124
124
  /** True only for the single internal continuation turn after a response stall. */
125
125
  autoRecovery?: boolean;
126
+ /** Adapter observed an authoritative completed final-response event. */
127
+ finalResponseObserved?: boolean;
126
128
  /** Adapter-provided callback to close the underlying SDK session / subprocess.
127
129
  * Called by stop-stuck-loop before controller.abort() to terminate the CLI
128
130
  * process immediately, rather than waiting for the async generator to unblock. */
package/src/session.ts CHANGED
@@ -166,7 +166,8 @@ const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
166
166
  const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
167
167
  const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
168
168
  export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
169
- export const RESPONSE_STALL_RECOVERY_NOTICE = "检测到会话停滞,正在自动确认并继续。";
169
+ export const RESPONSE_STALL_RECOVERY_NOTICE =
170
+ `检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
170
171
  export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
171
172
  "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
172
173
  const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
@@ -997,9 +998,19 @@ export async function runAgentSession(
997
998
  traceId?: string,
998
999
  options: RunAgentSessionOptions = {},
999
1000
  ): Promise<void> {
1000
- const tid = traceId ?? "";
1001
-
1002
- // 记录用户最后发送消息的群(display loop 只推送到该群)
1001
+ const tid = traceId ?? "";
1002
+
1003
+ // runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
1004
+ // prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
1005
+ // 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
1006
+ // 相同的卡片、状态和收尾行为。
1007
+ const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
1008
+ if (previousSessionId && previousSessionId !== sessionId) {
1009
+ unbindChatFromSession(previousSessionId, _chatId);
1010
+ }
1011
+ bindChatToSession(sessionId, _chatId);
1012
+
1013
+ // 记录用户最后发送消息的群(display loop 只推送到该群)
1003
1014
  // 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
1004
1015
  recordChatPlatform(_chatId, platform);
1005
1016
  recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
@@ -1025,6 +1036,7 @@ export async function runAgentSession(
1025
1036
  stopped: false,
1026
1037
  startTime: now,
1027
1038
  autoRecovery: options.autoRecovery === true,
1039
+ finalResponseObserved: false,
1028
1040
  });
1029
1041
 
1030
1042
  // 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
@@ -1269,6 +1281,7 @@ export async function runAgentSession(
1269
1281
  || current.abnormalExit
1270
1282
  || current.resourceStuck
1271
1283
  || current.autoEnded
1284
+ || current.finalResponseObserved
1272
1285
  || activityTracker.activity.kind !== "responding"
1273
1286
  || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
1274
1287
  ) {
@@ -1305,6 +1318,18 @@ export async function runAgentSession(
1305
1318
  autoEndedAt,
1306
1319
  });
1307
1320
 
1321
+ // 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
1322
+ // final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
1323
+ if (current.finalResponseObserved) {
1324
+ current.autoEnded = false;
1325
+ current.autoEndedAt = undefined;
1326
+ cancelAutoRecoveryReservation(sessionId);
1327
+ console.log(
1328
+ `[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`,
1329
+ );
1330
+ return;
1331
+ }
1332
+
1308
1333
  try {
1309
1334
  current.closeSession?.();
1310
1335
  } catch (err) {
@@ -1336,11 +1361,20 @@ export async function runAgentSession(
1336
1361
  clearPromptProcessMonitor(sessionId);
1337
1362
  if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
1338
1363
  },
1339
- onSessionCreated: (closeSession) => {
1340
- const prompt = activePrompts.get(sessionId);
1341
- if (prompt) prompt.closeSession = closeSession;
1342
- },
1343
- })) {
1364
+ onSessionCreated: (closeSession) => {
1365
+ const prompt = activePrompts.get(sessionId);
1366
+ if (prompt) prompt.closeSession = closeSession;
1367
+ },
1368
+ })) {
1369
+ if (unifiedMsg.isFinalResponse) {
1370
+ const prompt = activePrompts.get(sessionId);
1371
+ if (prompt && prompt === runningPrompt) {
1372
+ // 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
1373
+ // 最终事件后仍把本轮判为停滞。
1374
+ prompt.finalResponseObserved = true;
1375
+ }
1376
+ }
1377
+
1344
1378
  let activityChanged = false;
1345
1379
  for (const block of unifiedMsg.blocks) {
1346
1380
  if (updateAgentActivity(activityTracker, block)) activityChanged = true;
@@ -1401,27 +1435,46 @@ export async function runAgentSession(
1401
1435
  const wasStopped = prompt?.stopped ?? false;
1402
1436
  const wasAbnormalExit = prompt?.abnormalExit ?? false;
1403
1437
  const wasResourceStuck = prompt?.resourceStuck ?? false;
1404
- const wasAutoEnded = prompt?.autoEnded ?? false;
1438
+ const timeoutTriggered = prompt?.autoEnded ?? false;
1439
+ const completedAtTimeoutBoundary =
1440
+ timeoutTriggered && (prompt?.finalResponseObserved ?? false);
1441
+ const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
1405
1442
  const wasAutoRecovery = prompt?.autoRecovery ?? false;
1406
- const autoEndedAt = prompt?.autoEndedAt;
1443
+ const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1407
1444
  clearPromptResponseStallMonitor(sessionId);
1408
1445
  clearPromptProcessMonitor(sessionId);
1409
1446
  markSessionFinalizing(sessionId);
1410
1447
  activePrompts.delete(sessionId);
1411
1448
 
1412
1449
  try {
1413
- // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1414
- // 读到新状态并终结旧卡片。否则 setImmediate CHECK 阶段先于
1415
- // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1416
- // 运行中并更新旧卡片,而不是新建卡片。
1417
- const finalStatus = wasAutoEnded
1418
- ? "auto_ended"
1419
- : (streamErrored || wasAbnormalExit || wasResourceStuck)
1420
- ? "error"
1421
- : wasStopped
1422
- ? "stopped"
1423
- : "done";
1424
- const finalReply = pickFinalReply(state).trim();
1450
+ if (completedAtTimeoutBoundary) {
1451
+ // reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
1452
+ // abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
1453
+ cancelAutoRecoveryReservation(sessionId);
1454
+ console.log(
1455
+ `[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`,
1456
+ );
1457
+ }
1458
+ // 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
1459
+ // 永久残留 running=true。
1460
+ const finalizationChatIds = [...new Set([
1461
+ ...getChatsForSession(sessionId),
1462
+ _chatId,
1463
+ ])];
1464
+ // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1465
+ // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1466
+ // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1467
+ // 运行中并更新旧卡片,而不是新建卡片。
1468
+ const finalStatus = completedAtTimeoutBoundary
1469
+ ? "done"
1470
+ : wasAutoEnded
1471
+ ? "auto_ended"
1472
+ : (streamErrored || wasAbnormalExit || wasResourceStuck)
1473
+ ? "error"
1474
+ : wasStopped
1475
+ ? "stopped"
1476
+ : "done";
1477
+ const finalReply = pickFinalReply(state).trim();
1425
1478
 
1426
1479
  // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1427
1480
  // stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
@@ -1459,7 +1512,7 @@ export async function runAgentSession(
1459
1512
  let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
1460
1513
 
1461
1514
  if (wasStopped) {
1462
- for (const cid of getChatsForSession(sessionId)) {
1515
+ for (const cid of finalizationChatIds) {
1463
1516
  const finfo = sessionInfoMap.get(cid);
1464
1517
  await recordSessionRegistry({
1465
1518
  chatId: cid,
@@ -1471,7 +1524,7 @@ export async function runAgentSession(
1471
1524
  running: false,
1472
1525
  });
1473
1526
  }
1474
- const active1 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1527
+ const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1475
1528
  if (active1) {
1476
1529
  await platform.sendText(active1, "会话已停止。").catch(() => {});
1477
1530
  platform.setChatAvatar(active1, tool, "idle").catch(() => {});
@@ -1479,7 +1532,7 @@ export async function runAgentSession(
1479
1532
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1480
1533
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1481
1534
  } else if (wasAutoEnded) {
1482
- for (const cid of getChatsForSession(sessionId)) {
1535
+ for (const cid of finalizationChatIds) {
1483
1536
  const finfo = sessionInfoMap.get(cid);
1484
1537
  await recordSessionRegistry({
1485
1538
  chatId: cid,
@@ -1491,7 +1544,7 @@ export async function runAgentSession(
1491
1544
  running: false,
1492
1545
  });
1493
1546
  }
1494
- const activeAutoEnded = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1547
+ const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1495
1548
  if (activeAutoEnded) {
1496
1549
  const pp = platformForChat(activeAutoEnded) ?? platform;
1497
1550
  const terminalState = await readStreamState(sessionId);
@@ -1526,7 +1579,7 @@ export async function runAgentSession(
1526
1579
  console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
1527
1580
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1528
1581
  } else if (wasAbnormalExit) {
1529
- for (const cid of getChatsForSession(sessionId)) {
1582
+ for (const cid of finalizationChatIds) {
1530
1583
  const finfo = sessionInfoMap.get(cid);
1531
1584
  await recordSessionRegistry({
1532
1585
  chatId: cid,
@@ -1538,12 +1591,12 @@ export async function runAgentSession(
1538
1591
  running: false,
1539
1592
  });
1540
1593
  }
1541
- const activeErr = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1594
+ const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1542
1595
  if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
1543
1596
  console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1544
1597
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1545
- } else {
1546
- for (const cid of getChatsForSession(sessionId)) {
1598
+ } else {
1599
+ for (const cid of finalizationChatIds) {
1547
1600
  const finfo = sessionInfoMap.get(cid);
1548
1601
  await recordSessionRegistry({
1549
1602
  chatId: cid,
@@ -1555,7 +1608,7 @@ export async function runAgentSession(
1555
1608
  running: false,
1556
1609
  });
1557
1610
  }
1558
- const active2 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1611
+ const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1559
1612
  if (active2) {
1560
1613
  const terminalState = await readStreamState(sessionId);
1561
1614
  if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {