chatccc 0.2.209 → 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.209",
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,6 +672,7 @@ describe("runAgentSession process monitor", () => {
670
672
 
671
673
  describe("runAgentSession response stall watchdog", () => {
672
674
  let tempDir = "";
675
+ const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
673
676
 
674
677
  beforeEach(async () => {
675
678
  vi.useFakeTimers();
@@ -766,6 +769,376 @@ describe("runAgentSession response stall watchdog", () => {
766
769
  autoEndedAt: expect.any(Number),
767
770
  });
768
771
  });
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
+
922
+ it("runs the reserved recovery prompt before an already queued user message", async () => {
923
+ vi.setSystemTime(0);
924
+ _setResponseStallTimeoutForTest(100);
925
+ _setResponseStallCheckIntervalForTest(10);
926
+ _setProcessAliveForTest(() => true);
927
+
928
+ const platform = mockPlatform("feishu");
929
+ setSessionPlatform(platform);
930
+ bindChatToSession("sid-recovery-priority", "chat-recovery-priority");
931
+ recordLastActiveChat("sid-recovery-priority", "chat-recovery-priority");
932
+
933
+ const receivedPrompts: string[] = [];
934
+ const adapter: ToolAdapter = {
935
+ displayName: "Any Agent",
936
+ sessionDescPrefix: "Agent Session:",
937
+ createSession: async () => ({ sessionId: "sid-recovery-priority" }),
938
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
939
+ closeSession: async () => {},
940
+ prompt: async function* (
941
+ _sid: string,
942
+ text: string,
943
+ _cwd: string,
944
+ signal?: AbortSignal,
945
+ options?: ToolPromptOptions,
946
+ ) {
947
+ receivedPrompts.push(text);
948
+ options?.onProcessStart?.({ pid: 5252 + receivedPrompts.length });
949
+ if (receivedPrompts.length === 1) {
950
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
951
+ await new Promise<void>((resolve) => {
952
+ if (signal?.aborted) {
953
+ resolve();
954
+ return;
955
+ }
956
+ signal?.addEventListener("abort", () => resolve(), { once: true });
957
+ });
958
+ return;
959
+ }
960
+ yield { type: "assistant", blocks: [{ type: "text", text: "recovery completed" }] };
961
+ },
962
+ };
963
+ _setAdapterForToolForTest("claude", adapter);
964
+
965
+ enqueueMessage("sid-recovery-priority", {
966
+ text: "queued user prompt",
967
+ chatId: "chat-recovery-priority",
968
+ openId: "open-user",
969
+ msgTimestamp: 1,
970
+ chatType: "p2p",
971
+ });
972
+ const consumeQueued = vi.fn();
973
+ setQueueConsumer(consumeQueued);
974
+
975
+ const firstRun = runAgentSession(
976
+ "sid-recovery-priority",
977
+ "first prompt",
978
+ platform,
979
+ "chat-recovery-priority",
980
+ 0,
981
+ "claude",
982
+ );
983
+
984
+ await vi.waitFor(() => {
985
+ expect(activePrompts.get("sid-recovery-priority")?.responseProgress).toBeDefined();
986
+ });
987
+ const firstProgress = activePrompts.get("sid-recovery-priority")!.responseProgress!;
988
+ await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
989
+ await firstRun;
990
+
991
+ expect(receivedPrompts).toHaveLength(1);
992
+ expect(consumeQueued).not.toHaveBeenCalled();
993
+ expect(isSessionRunning("sid-recovery-priority")).toBe(true);
994
+ expect(platform.sendText).toHaveBeenCalledWith(
995
+ "chat-recovery-priority",
996
+ expect.stringContaining(recoveryPrompt),
997
+ );
998
+
999
+ await vi.advanceTimersByTimeAsync(200);
1000
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
1001
+ expect(receivedPrompts[1]).toContain(recoveryPrompt);
1002
+ expect(receivedPrompts[1]).not.toContain("queued user prompt");
1003
+ expect(consumeQueued).not.toHaveBeenCalled();
1004
+
1005
+ await vi.advanceTimersByTimeAsync(200);
1006
+ expect(consumeQueued).toHaveBeenCalledTimes(1);
1007
+ expect(consumeQueued).toHaveBeenCalledWith(
1008
+ platform,
1009
+ expect.objectContaining({ text: "queued user prompt" }),
1010
+ );
1011
+ setQueueConsumer(() => {});
1012
+ });
1013
+
1014
+ it("does not auto-recover a second consecutive response stall", async () => {
1015
+ vi.setSystemTime(0);
1016
+ _setResponseStallTimeoutForTest(100);
1017
+ _setResponseStallCheckIntervalForTest(10);
1018
+ _setProcessAliveForTest(() => true);
1019
+
1020
+ const platform = mockPlatform("feishu");
1021
+ setSessionPlatform(platform);
1022
+ bindChatToSession("sid-recovery-limit", "chat-recovery-limit");
1023
+ recordLastActiveChat("sid-recovery-limit", "chat-recovery-limit");
1024
+
1025
+ const receivedPrompts: string[] = [];
1026
+ const adapter: ToolAdapter = {
1027
+ displayName: "Any Agent",
1028
+ sessionDescPrefix: "Agent Session:",
1029
+ createSession: async () => ({ sessionId: "sid-recovery-limit" }),
1030
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1031
+ closeSession: async () => {},
1032
+ prompt: async function* (
1033
+ _sid: string,
1034
+ text: string,
1035
+ _cwd: string,
1036
+ signal?: AbortSignal,
1037
+ options?: ToolPromptOptions,
1038
+ ) {
1039
+ receivedPrompts.push(text);
1040
+ options?.onProcessStart?.({ pid: 6262 + receivedPrompts.length });
1041
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1042
+ await new Promise<void>((resolve) => {
1043
+ if (signal?.aborted) {
1044
+ resolve();
1045
+ return;
1046
+ }
1047
+ signal?.addEventListener("abort", () => resolve(), { once: true });
1048
+ });
1049
+ },
1050
+ };
1051
+ _setAdapterForToolForTest("claude", adapter);
1052
+
1053
+ const firstRun = runAgentSession(
1054
+ "sid-recovery-limit",
1055
+ "first prompt",
1056
+ platform,
1057
+ "chat-recovery-limit",
1058
+ 0,
1059
+ "claude",
1060
+ );
1061
+ await vi.waitFor(() => {
1062
+ expect(activePrompts.get("sid-recovery-limit")?.responseProgress).toBeDefined();
1063
+ });
1064
+ const firstProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
1065
+ await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
1066
+ await firstRun;
1067
+
1068
+ await vi.advanceTimersByTimeAsync(200);
1069
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
1070
+ const recoveryProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
1071
+ await vi.advanceTimersByTimeAsync(recoveryProgress.unchangedSince + 101 - Date.now());
1072
+ await vi.waitFor(() => expect(isSessionRunning("sid-recovery-limit")).toBe(false));
1073
+
1074
+ await vi.advanceTimersByTimeAsync(1_000);
1075
+ expect(receivedPrompts).toHaveLength(2);
1076
+ expect(receivedPrompts[1]).toContain(recoveryPrompt);
1077
+ expect(platform.sendText).toHaveBeenCalledWith(
1078
+ "chat-recovery-limit",
1079
+ "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。",
1080
+ );
1081
+ });
1082
+
1083
+ it("lets /stop cancel a reserved recovery before it starts", async () => {
1084
+ vi.setSystemTime(0);
1085
+ _setResponseStallTimeoutForTest(100);
1086
+ _setResponseStallCheckIntervalForTest(10);
1087
+ _setProcessAliveForTest(() => true);
1088
+
1089
+ const platform = mockPlatform("feishu");
1090
+ setSessionPlatform(platform);
1091
+ bindChatToSession("sid-recovery-stop", "chat-recovery-stop");
1092
+ recordLastActiveChat("sid-recovery-stop", "chat-recovery-stop");
1093
+
1094
+ const receivedPrompts: string[] = [];
1095
+ const adapter: ToolAdapter = {
1096
+ displayName: "Any Agent",
1097
+ sessionDescPrefix: "Agent Session:",
1098
+ createSession: async () => ({ sessionId: "sid-recovery-stop" }),
1099
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1100
+ closeSession: async () => {},
1101
+ prompt: async function* (
1102
+ _sid: string,
1103
+ text: string,
1104
+ _cwd: string,
1105
+ signal?: AbortSignal,
1106
+ ) {
1107
+ receivedPrompts.push(text);
1108
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1109
+ await new Promise<void>((resolve) => {
1110
+ if (signal?.aborted) {
1111
+ resolve();
1112
+ return;
1113
+ }
1114
+ signal?.addEventListener("abort", () => resolve(), { once: true });
1115
+ });
1116
+ },
1117
+ };
1118
+ _setAdapterForToolForTest("claude", adapter);
1119
+
1120
+ const firstRun = runAgentSession(
1121
+ "sid-recovery-stop",
1122
+ "first prompt",
1123
+ platform,
1124
+ "chat-recovery-stop",
1125
+ 0,
1126
+ "claude",
1127
+ );
1128
+ await vi.waitFor(() => {
1129
+ expect(activePrompts.get("sid-recovery-stop")?.responseProgress).toBeDefined();
1130
+ });
1131
+ const progress = activePrompts.get("sid-recovery-stop")!.responseProgress!;
1132
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
1133
+ await firstRun;
1134
+
1135
+ expect(isSessionRunning("sid-recovery-stop")).toBe(true);
1136
+ expect(stopSession("sid-recovery-stop")).toBe(true);
1137
+ await vi.advanceTimersByTimeAsync(500);
1138
+
1139
+ expect(receivedPrompts).toHaveLength(1);
1140
+ expect(isSessionRunning("sid-recovery-stop")).toBe(false);
1141
+ });
769
1142
  });
770
1143
 
771
1144
  describe("unified display loop WeChat delta", () => {
@@ -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",
@@ -57,9 +57,17 @@ export function hasChatsForSession(sessionId: string): boolean {
57
57
  // 也必须阻止下一轮提前进入,否则旧会话的落盘可能覆盖新会话绑定。
58
58
  const finalizingSessions = new Set<string>();
59
59
 
60
- /** 检查 sessionId 是否有活跃 prompt 或正在完成本轮异步收尾。 */
60
+ // response-stall 自动恢复预约只存在于当前进程内:用户已明确 ChatCCC 重启后
61
+ // 不需要补发恢复 prompt。预约从检测到第一次停滞起一直持有到恢复轮同步进入
62
+ // runAgentSession,填补旧轮收尾和新轮 activePrompts.set 之间的空窗,保证此时
63
+ // 到达的普通用户消息只能进入缓存队列,不能抢在恢复 prompt 前执行。
64
+ const autoRecoveryReservations = new Set<string>();
65
+
66
+ /** 检查 sessionId 是否有活跃 prompt、正在收尾或已预约自动恢复。 */
61
67
  export function isSessionRunning(sessionId: string): boolean {
62
- return activePrompts.has(sessionId) || finalizingSessions.has(sessionId);
68
+ return activePrompts.has(sessionId)
69
+ || finalizingSessions.has(sessionId)
70
+ || autoRecoveryReservations.has(sessionId);
63
71
  }
64
72
 
65
73
  export function markSessionFinalizing(sessionId: string): void {
@@ -69,6 +77,27 @@ export function markSessionFinalizing(sessionId: string): void {
69
77
  export function clearSessionFinalizing(sessionId: string): void {
70
78
  finalizingSessions.delete(sessionId);
71
79
  }
80
+
81
+ /** 为 response-stall 后的内部恢复轮预留下一次执行权。 */
82
+ export function reserveAutoRecovery(sessionId: string): boolean {
83
+ if (autoRecoveryReservations.has(sessionId)) return false;
84
+ autoRecoveryReservations.add(sessionId);
85
+ return true;
86
+ }
87
+
88
+ /** 恢复轮启动前原子消费预约;返回 false 表示已被 /stop 取消。 */
89
+ export function consumeAutoRecoveryReservation(sessionId: string): boolean {
90
+ return autoRecoveryReservations.delete(sessionId);
91
+ }
92
+
93
+ /** 取消尚未启动的恢复轮。 */
94
+ export function cancelAutoRecoveryReservation(sessionId: string): boolean {
95
+ return autoRecoveryReservations.delete(sessionId);
96
+ }
97
+
98
+ export function hasAutoRecoveryReservation(sessionId: string): boolean {
99
+ return autoRecoveryReservations.has(sessionId);
100
+ }
72
101
 
73
102
  // ---------------------------------------------------------------------------
74
103
  // activePrompts: sessionId → 活跃 prompt 控制
@@ -92,6 +121,10 @@ export interface ActivePrompt {
92
121
  abnormalExitNotified?: boolean;
93
122
  /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
94
123
  resourceStuck?: boolean;
124
+ /** True only for the single internal continuation turn after a response stall. */
125
+ autoRecovery?: boolean;
126
+ /** Adapter observed an authoritative completed final-response event. */
127
+ finalResponseObserved?: boolean;
95
128
  /** Adapter-provided callback to close the underlying SDK session / subprocess.
96
129
  * Called by stop-stuck-loop before controller.abort() to terminate the CLI
97
130
  * process immediately, rather than waiting for the async generator to unblock. */
@@ -245,6 +278,7 @@ export function resetBindingState(): void {
245
278
  }
246
279
  activePrompts.clear();
247
280
  finalizingSessions.clear();
281
+ autoRecoveryReservations.clear();
248
282
  queuedMessages.clear();
249
283
  displayCards.clear();
250
284
  if (unifiedDisplayLoopHandle !== null) {
package/src/session.ts CHANGED
@@ -77,6 +77,10 @@ import {
77
77
  consumeQueuePreservedChat,
78
78
  markSessionFinalizing,
79
79
  clearSessionFinalizing,
80
+ reserveAutoRecovery,
81
+ consumeAutoRecoveryReservation,
82
+ cancelAutoRecoveryReservation,
83
+ hasAutoRecoveryReservation,
80
84
  } from "./session-chat-binding.ts";
81
85
 
82
86
  async function sendFinalReplyTextOnce(
@@ -161,6 +165,12 @@ function platformForChat(chatId: string): PlatformAdapter | null {
161
165
  const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
162
166
  const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
163
167
  const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
168
+ export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
169
+ export const RESPONSE_STALL_RECOVERY_NOTICE =
170
+ `检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
171
+ export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
172
+ "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
173
+ const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
164
174
  let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
165
175
  let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
166
176
  let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
@@ -966,22 +976,41 @@ export async function resumeAndPrompt(
966
976
  return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
967
977
  }
968
978
 
969
- // ---------------------------------------------------------------------------
970
- // runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
971
- // ---------------------------------------------------------------------------
972
-
973
- export async function runAgentSession(
974
- sessionId: string,
975
- userText: string,
976
- platform: PlatformAdapter,
977
- _chatId: string,
978
- msgTimestamp: number,
979
- tool: string,
980
- traceId?: string,
981
- ): Promise<void> {
982
- const tid = traceId ?? "";
983
-
984
- // 记录用户最后发送消息的群(display loop 只推送到该群)
979
+ // ---------------------------------------------------------------------------
980
+ // runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
981
+ // ---------------------------------------------------------------------------
982
+
983
+ interface RunAgentSessionOptions {
984
+ /**
985
+ * 标记本轮是 response-stall 后的唯一一次内部续跑。若本轮再次因相同原因
986
+ * 停滞,不再递归创建第三轮,避免服务异常期间无限消耗 token。
987
+ */
988
+ autoRecovery?: boolean;
989
+ }
990
+
991
+ export async function runAgentSession(
992
+ sessionId: string,
993
+ userText: string,
994
+ platform: PlatformAdapter,
995
+ _chatId: string,
996
+ msgTimestamp: number,
997
+ tool: string,
998
+ traceId?: string,
999
+ options: RunAgentSessionOptions = {},
1000
+ ): Promise<void> {
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 只推送到该群)
985
1014
  // 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
986
1015
  recordChatPlatform(_chatId, platform);
987
1016
  recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
@@ -1002,11 +1031,13 @@ export async function runAgentSession(
1002
1031
  // 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
1003
1032
  const controller = new AbortController();
1004
1033
  const now = Date.now();
1005
- activePrompts.set(sessionId, {
1006
- controller,
1007
- stopped: false,
1008
- startTime: now,
1009
- });
1034
+ activePrompts.set(sessionId, {
1035
+ controller,
1036
+ stopped: false,
1037
+ startTime: now,
1038
+ autoRecovery: options.autoRecovery === true,
1039
+ finalResponseObserved: false,
1040
+ });
1010
1041
 
1011
1042
  // 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
1012
1043
  const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
@@ -1250,6 +1281,7 @@ export async function runAgentSession(
1250
1281
  || current.abnormalExit
1251
1282
  || current.resourceStuck
1252
1283
  || current.autoEnded
1284
+ || current.finalResponseObserved
1253
1285
  || activityTracker.activity.kind !== "responding"
1254
1286
  || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
1255
1287
  ) {
@@ -1257,6 +1289,13 @@ export async function runAgentSession(
1257
1289
  }
1258
1290
 
1259
1291
  const autoEndedAt = Date.now();
1292
+ // 普通轮第一次因回复停滞结束时,立即预约同 session 的内部续跑。
1293
+ // 预约先于 abort/收尾建立,isSessionRunning 会在整个交接窗口保持 true,
1294
+ // 因此恰好到达的用户消息只能排队,绝不可能抢在恢复 prompt 前。
1295
+ // 若当前已经是恢复轮,则不再预约第三轮。
1296
+ if (!current.autoRecovery) {
1297
+ reserveAutoRecovery(sessionId);
1298
+ }
1260
1299
  current.autoEnded = true;
1261
1300
  current.autoEndedAt = autoEndedAt;
1262
1301
  clearPromptResponseStallMonitor(sessionId);
@@ -1279,6 +1318,18 @@ export async function runAgentSession(
1279
1318
  autoEndedAt,
1280
1319
  });
1281
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
+
1282
1333
  try {
1283
1334
  current.closeSession?.();
1284
1335
  } catch (err) {
@@ -1310,11 +1361,20 @@ export async function runAgentSession(
1310
1361
  clearPromptProcessMonitor(sessionId);
1311
1362
  if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
1312
1363
  },
1313
- onSessionCreated: (closeSession) => {
1314
- const prompt = activePrompts.get(sessionId);
1315
- if (prompt) prompt.closeSession = closeSession;
1316
- },
1317
- })) {
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
+
1318
1378
  let activityChanged = false;
1319
1379
  for (const block of unifiedMsg.blocks) {
1320
1380
  if (updateAgentActivity(activityTracker, block)) activityChanged = true;
@@ -1375,26 +1435,46 @@ export async function runAgentSession(
1375
1435
  const wasStopped = prompt?.stopped ?? false;
1376
1436
  const wasAbnormalExit = prompt?.abnormalExit ?? false;
1377
1437
  const wasResourceStuck = prompt?.resourceStuck ?? false;
1378
- const wasAutoEnded = prompt?.autoEnded ?? false;
1379
- const autoEndedAt = prompt?.autoEndedAt;
1438
+ const timeoutTriggered = prompt?.autoEnded ?? false;
1439
+ const completedAtTimeoutBoundary =
1440
+ timeoutTriggered && (prompt?.finalResponseObserved ?? false);
1441
+ const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
1442
+ const wasAutoRecovery = prompt?.autoRecovery ?? false;
1443
+ const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1380
1444
  clearPromptResponseStallMonitor(sessionId);
1381
1445
  clearPromptProcessMonitor(sessionId);
1382
1446
  markSessionFinalizing(sessionId);
1383
1447
  activePrompts.delete(sessionId);
1384
1448
 
1385
1449
  try {
1386
- // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1387
- // 读到新状态并终结旧卡片。否则 setImmediate CHECK 阶段先于
1388
- // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1389
- // 运行中并更新旧卡片,而不是新建卡片。
1390
- const finalStatus = wasAutoEnded
1391
- ? "auto_ended"
1392
- : (streamErrored || wasAbnormalExit || wasResourceStuck)
1393
- ? "error"
1394
- : wasStopped
1395
- ? "stopped"
1396
- : "done";
1397
- 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();
1398
1478
 
1399
1479
  // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1400
1480
  // stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
@@ -1426,11 +1506,13 @@ export async function runAgentSession(
1426
1506
  ...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
1427
1507
  ...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
1428
1508
  });
1429
-
1509
+
1430
1510
  // display loop 下一轮会读到最终状态并发送消息
1431
-
1432
- if (wasStopped) {
1433
- for (const cid of getChatsForSession(sessionId)) {
1511
+
1512
+ let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
1513
+
1514
+ if (wasStopped) {
1515
+ for (const cid of finalizationChatIds) {
1434
1516
  const finfo = sessionInfoMap.get(cid);
1435
1517
  await recordSessionRegistry({
1436
1518
  chatId: cid,
@@ -1442,7 +1524,7 @@ export async function runAgentSession(
1442
1524
  running: false,
1443
1525
  });
1444
1526
  }
1445
- const active1 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1527
+ const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1446
1528
  if (active1) {
1447
1529
  await platform.sendText(active1, "会话已停止。").catch(() => {});
1448
1530
  platform.setChatAvatar(active1, tool, "idle").catch(() => {});
@@ -1450,7 +1532,7 @@ export async function runAgentSession(
1450
1532
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1451
1533
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1452
1534
  } else if (wasAutoEnded) {
1453
- for (const cid of getChatsForSession(sessionId)) {
1535
+ for (const cid of finalizationChatIds) {
1454
1536
  const finfo = sessionInfoMap.get(cid);
1455
1537
  await recordSessionRegistry({
1456
1538
  chatId: cid,
@@ -1462,11 +1544,11 @@ export async function runAgentSession(
1462
1544
  running: false,
1463
1545
  });
1464
1546
  }
1465
- const activeAutoEnded = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1547
+ const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1466
1548
  if (activeAutoEnded) {
1549
+ const pp = platformForChat(activeAutoEnded) ?? platform;
1467
1550
  const terminalState = await readStreamState(sessionId);
1468
1551
  if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1469
- const pp = platformForChat(activeAutoEnded) ?? platform;
1470
1552
  await sendFinalReplyTextOnce(
1471
1553
  pp,
1472
1554
  activeAutoEnded,
@@ -1475,12 +1557,29 @@ export async function runAgentSession(
1475
1557
  formatAutoEndedReply(finalReplyToWrite),
1476
1558
  );
1477
1559
  }
1478
- platform.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
1560
+ pp.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
1561
+
1562
+ if (wasAutoRecovery) {
1563
+ // 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
1564
+ // 自动链,避免第三轮及之后的无限续跑。
1565
+ await pp.sendText(
1566
+ activeAutoEnded,
1567
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
1568
+ ).catch(() => {});
1569
+ } else if (hasAutoRecoveryReservation(sessionId)) {
1570
+ // 用户可见提示与内部恢复 prompt 分离:提示发送失败不影响恢复,
1571
+ // 内部恢复仍由 reservation 保证先于普通缓存消息。
1572
+ await pp.sendText(
1573
+ activeAutoEnded,
1574
+ RESPONSE_STALL_RECOVERY_NOTICE,
1575
+ ).catch(() => {});
1576
+ autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
1577
+ }
1479
1578
  }
1480
1579
  console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
1481
1580
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1482
1581
  } else if (wasAbnormalExit) {
1483
- for (const cid of getChatsForSession(sessionId)) {
1582
+ for (const cid of finalizationChatIds) {
1484
1583
  const finfo = sessionInfoMap.get(cid);
1485
1584
  await recordSessionRegistry({
1486
1585
  chatId: cid,
@@ -1492,12 +1591,12 @@ export async function runAgentSession(
1492
1591
  running: false,
1493
1592
  });
1494
1593
  }
1495
- const activeErr = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1594
+ const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1496
1595
  if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
1497
1596
  console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1498
1597
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1499
- } else {
1500
- for (const cid of getChatsForSession(sessionId)) {
1598
+ } else {
1599
+ for (const cid of finalizationChatIds) {
1501
1600
  const finfo = sessionInfoMap.get(cid);
1502
1601
  await recordSessionRegistry({
1503
1602
  chatId: cid,
@@ -1509,7 +1608,7 @@ export async function runAgentSession(
1509
1608
  running: false,
1510
1609
  });
1511
1610
  }
1512
- const active2 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1611
+ const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1513
1612
  if (active2) {
1514
1613
  const terminalState = await readStreamState(sessionId);
1515
1614
  if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
@@ -1522,6 +1621,18 @@ export async function runAgentSession(
1522
1621
  if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1523
1622
  }
1524
1623
 
1624
+ // 失去聊天绑定时无法安全选择恢复轮的展示目标,取消本次进程内预约。
1625
+ if (
1626
+ wasAutoEnded
1627
+ && hasAutoRecoveryReservation(sessionId)
1628
+ && !autoRecoveryTarget
1629
+ ) {
1630
+ cancelAutoRecoveryReservation(sessionId);
1631
+ }
1632
+ const shouldScheduleAutoRecovery =
1633
+ autoRecoveryTarget !== undefined
1634
+ && hasAutoRecoveryReservation(sessionId);
1635
+
1525
1636
  // 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
1526
1637
  // 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
1527
1638
  let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
@@ -1530,7 +1641,9 @@ export async function runAgentSession(
1530
1641
  if (discarded) {
1531
1642
  console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
1532
1643
  }
1533
- } else {
1644
+ } else if (!shouldScheduleAutoRecovery) {
1645
+ // 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
1646
+ // finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
1534
1647
  queuedForConsumption = dequeueMessage(sessionId);
1535
1648
  }
1536
1649
 
@@ -1548,7 +1661,48 @@ export async function runAgentSession(
1548
1661
  // 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
1549
1662
  setTimeout(() => {
1550
1663
  consumeQueuedMessage(platform, queued);
1551
- }, 200);
1664
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1665
+ }
1666
+
1667
+ if (shouldScheduleAutoRecovery && autoRecoveryTarget) {
1668
+ const target = autoRecoveryTarget;
1669
+ console.log(
1670
+ `[${ts()}] [RESPONSE-STALL] Reserved automatic recovery for session ${sessionId}`,
1671
+ );
1672
+ // 延迟与普通队列原有策略一致,让上一轮终态先完成展示。reservation
1673
+ // 在定时器等待期间仍令 isSessionRunning=true;调用 runAgentSession
1674
+ // 时会在首次 await 前同步写入 activePrompts,然后才消费 reservation,
1675
+ // 因而不存在普通用户消息可插入的事件循环空窗。
1676
+ setTimeout(() => {
1677
+ if (!hasAutoRecoveryReservation(sessionId)) return;
1678
+ const recoveryRun = runAgentSession(
1679
+ sessionId,
1680
+ RESPONSE_STALL_RECOVERY_PROMPT,
1681
+ target.platform,
1682
+ target.chatId,
1683
+ Date.now(),
1684
+ tool,
1685
+ undefined,
1686
+ { autoRecovery: true },
1687
+ );
1688
+ consumeAutoRecoveryReservation(sessionId);
1689
+ void recoveryRun.catch((err) => {
1690
+ console.error(
1691
+ `[${ts()}] [RESPONSE-STALL] Automatic recovery failed for ${sessionId}: ${(err as Error).message}`,
1692
+ );
1693
+ target.platform.sendText(
1694
+ target.chatId,
1695
+ `⚠️ 自动续跑启动失败:${(err as Error).message}`,
1696
+ ).catch(() => {});
1697
+
1698
+ // 若恢复轮在进入主 stream try/finally 前即准备失败,它不会自然
1699
+ // 消费此前保留的用户缓存;在错误回调中补做一次,避免队列悬挂。
1700
+ const queued = dequeueMessage(sessionId);
1701
+ if (queued) {
1702
+ consumeQueuedMessage(target.platform, queued);
1703
+ }
1704
+ });
1705
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1552
1706
  }
1553
1707
  } finally {
1554
1708
  clearSessionFinalizing(sessionId);
@@ -1910,9 +2064,19 @@ export function stopUnifiedDisplayLoop(): void {
1910
2064
  // 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
1911
2065
  // 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
1912
2066
  // finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
1913
- export function stopSession(sessionId: string): boolean {
1914
- const prompt = activePrompts.get(sessionId);
1915
- if (!prompt) return false;
2067
+ export function stopSession(sessionId: string): boolean {
2068
+ // /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
2069
+ // 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
2070
+ const cancelledRecovery = cancelAutoRecoveryReservation(sessionId);
2071
+ const prompt = activePrompts.get(sessionId);
2072
+ if (!prompt) {
2073
+ if (cancelledRecovery) {
2074
+ cancelQueuedMessage(sessionId);
2075
+ console.log(`[${ts()}] [STOP] Reserved automatic recovery for ${sessionId} cancelled`);
2076
+ return true;
2077
+ }
2078
+ return false;
2079
+ }
1916
2080
  prompt.stopped = true;
1917
2081
  clearPromptResponseStallMonitor(sessionId);
1918
2082
  clearPromptProcessMonitor(sessionId);