chatccc 0.2.210 → 0.2.212

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/README.md CHANGED
@@ -332,9 +332,11 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
332
332
 
333
333
  **飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor` 或 `/new codex`,机器人会另外创建会话群。
334
334
 
335
- **微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
336
-
337
- ## 可用指令
335
+ **微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
336
+
337
+ **会话停滞保护:** 当 Agent 连续 3 分钟处于“正在生成回复”且回复字符数没有变化、同时尚未报告权威终态时,ChatCCC 会结束旧 CLI,并优先补发一次“完成了吗?如果没完成继续”;恢复轮再次发生相同停滞时不再递归续跑。Codex 只有 `turn.completed` 才算权威终态,阶段性的 `agent_message` 不算;任一 Agent 报告权威终态后若输出流仍超过 10 秒未关闭,ChatCCC 会强制清理该 CLI 并按正常完成收尾,不会重复询问 Agent。
338
+
339
+ ## 可用指令
338
340
 
339
341
  | 指令 | 作用 |
340
342
  | --- | --- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.210",
3
+ "version": "0.2.212",
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
  });
@@ -52,6 +52,7 @@ describe("normalizeSdkMessage", () => {
52
52
  expect(result).not.toBeNull();
53
53
  expect(result!.type).toBe("assistant");
54
54
  expect(result!.blocks).toEqual([{ type: "text", text: "Hello world" }]);
55
+ expect(result!.isFinalResponse).toBeUndefined();
55
56
  });
56
57
 
57
58
  it("normalizes assistant message with thinking block", () => {
@@ -269,10 +270,17 @@ describe("normalizeSdkMessage", () => {
269
270
  expect(result!.blocks[0]).toEqual({ type: "text", text: "still here" });
270
271
  });
271
272
 
272
- it("returns null for non-assistant/non-user/non-system messages", () => {
273
+ it("marks a successful result event as an authoritative final response", () => {
273
274
  expect(
274
275
  normalizeSdkMessage({ type: "result", subtype: "success" }),
275
- ).toBeNull();
276
+ ).toEqual({
277
+ type: "assistant",
278
+ blocks: [],
279
+ isFinalResponse: true,
280
+ });
281
+ });
282
+
283
+ it("returns null for other non-assistant/non-user/non-system messages", () => {
276
284
  expect(
277
285
  normalizeSdkMessage({ type: "stream_event" }),
278
286
  ).toBeNull();
@@ -56,15 +56,16 @@ function createInMemoryMetaStore(
56
56
  // ---------------------------------------------------------------------------
57
57
 
58
58
  describe("normalizeCodexMessage", () => {
59
- it("normalizes agent_message into assistant text block", () => {
60
- const result = normalizeCodexMessage({
61
- type: "item.completed",
62
- item: { id: "item_0", type: "agent_message", text: "hello" },
59
+ it("normalizes agent_message into assistant text block", () => {
60
+ const result = normalizeCodexMessage({
61
+ type: "item.completed",
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).toBeUndefined();
68
+ });
68
69
 
69
70
  it("normalizes command_execution start into tool_use block", () => {
70
71
  const result = normalizeCodexMessage({
@@ -142,13 +143,17 @@ describe("normalizeCodexMessage", () => {
142
143
  expect(normalizeCodexMessage({ type: "turn.started" })).toBeNull();
143
144
  });
144
145
 
145
- it("returns null for turn.completed events", () => {
146
- expect(
147
- normalizeCodexMessage({
148
- type: "turn.completed",
149
- usage: { input_tokens: 100, output_tokens: 50 },
150
- }),
151
- ).toBeNull();
146
+ it("marks turn.completed as the authoritative final response", () => {
147
+ expect(
148
+ normalizeCodexMessage({
149
+ type: "turn.completed",
150
+ usage: { input_tokens: 100, output_tokens: 50 },
151
+ }),
152
+ ).toEqual({
153
+ type: "assistant",
154
+ blocks: [],
155
+ isFinalResponse: true,
156
+ });
152
157
  });
153
158
 
154
159
  it("returns null for unknown event types", () => {
@@ -224,7 +229,7 @@ describe("Codex stream fixtures", () => {
224
229
  expect(state.accumulatedContent).toContain("tool_test");
225
230
  });
226
231
 
227
- it("with tool: 只映射 agent_message 和 command_execution,不映射元事件", () => {
232
+ it("with tool: 普通输出不标终态,只有 turn.completed 标记终态", () => {
228
233
  const lines = readFixture("codex_with_tool.jsonl");
229
234
  const messages: UnifiedStreamMessage[] = [];
230
235
  for (const raw of lines) {
@@ -234,12 +239,18 @@ describe("Codex stream fixtures", () => {
234
239
  if (normalized) messages.push(normalized);
235
240
  }
236
241
 
237
- // 应有: tool_use + tool_result + text = 3 条消息
238
- expect(messages.length).toBe(3);
239
- expect(messages[0].blocks[0].type).toBe("tool_use");
240
- expect(messages[1].blocks[0].type).toBe("tool_result");
241
- expect(messages[2].blocks[0].type).toBe("text");
242
- });
242
+ // 应有: tool_use + tool_result + text + turn.completed = 4 条消息
243
+ expect(messages.length).toBe(4);
244
+ expect(messages[0].blocks[0].type).toBe("tool_use");
245
+ expect(messages[1].blocks[0].type).toBe("tool_result");
246
+ expect(messages[2].blocks[0].type).toBe("text");
247
+ expect(messages[2].isFinalResponse).toBeUndefined();
248
+ expect(messages[3]).toEqual({
249
+ type: "assistant",
250
+ blocks: [],
251
+ isFinalResponse: true,
252
+ });
253
+ });
243
254
  });
244
255
 
245
256
  // ---------------------------------------------------------------------------
@@ -136,10 +136,17 @@ describe("Codex raw stream logs", () => {
136
136
  expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, lines[1]);
137
137
  expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(3, lines[2]);
138
138
  expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: false });
139
- expect(events).toContainEqual({
140
- type: "assistant",
141
- blocks: [{ type: "text", text: "hello" }],
142
- });
139
+ expect(events).toEqual([
140
+ {
141
+ type: "assistant",
142
+ blocks: [{ type: "text", text: "hello" }],
143
+ },
144
+ {
145
+ type: "assistant",
146
+ blocks: [],
147
+ isFinalResponse: true,
148
+ },
149
+ ]);
143
150
  });
144
151
 
145
152
  it("fails the turn and kills the process tree when bad JSON is followed by idle stdout", async () => {
@@ -445,9 +445,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
445
445
  type: "assistant",
446
446
  message: { role: "assistant", content: [{ type: "text", text: "你" }] },
447
447
  timestamp_ms: 1778411927583,
448
- });
449
- expect(result).not.toBeNull();
450
- expect(result!.blocks).toEqual([{ type: "text", text: "你" }]);
448
+ });
449
+ expect(result).not.toBeNull();
450
+ expect(result!.blocks).toEqual([{ type: "text", text: "你" }]);
451
+ expect(result!.isFinalResponse).toBeUndefined();
451
452
  });
452
453
 
453
454
  it("Buffered flush(has timestamp_ms, has model_call_id)→ text_final(覆盖,避免与 delta 重复累加)", () => {
@@ -461,9 +462,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
461
462
  model_call_id: "mc-1",
462
463
  } as Parameters<typeof normalizeCursorMessage>[0]);
463
464
  expect(result).not.toBeNull();
464
- expect(result!.blocks).toEqual([
465
- { type: "text_final", text: "完整快照(与 delta 累计相同)" },
466
- ]);
465
+ expect(result!.blocks).toEqual([
466
+ { type: "text_final", text: "完整快照(与 delta 累计相同)" },
467
+ ]);
468
+ expect(result!.isFinalResponse).toBeUndefined();
467
469
  });
468
470
 
469
471
  it("Final flush(no timestamp_ms)→ text_final(覆盖)", () => {
@@ -475,9 +477,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
475
477
  },
476
478
  });
477
479
  expect(result).not.toBeNull();
478
- expect(result!.blocks).toEqual([
479
- { type: "text_final", text: "你上一题问的是 1+2=?" },
480
- ]);
480
+ expect(result!.blocks).toEqual([
481
+ { type: "text_final", text: "你上一题问的是 1+2=?" },
482
+ ]);
483
+ expect(result!.isFinalResponse).toBeUndefined();
481
484
  });
482
485
  });
483
486
 
@@ -490,10 +493,11 @@ describe("normalizeCursorMessage - result 消息(官方权威最终文本)",
490
493
  });
491
494
  expect(result).not.toBeNull();
492
495
  expect(result!.type).toBe("assistant");
493
- expect(result!.blocks).toEqual([
494
- { type: "text_final", text: "权威最终文本" },
495
- ]);
496
- });
496
+ expect(result!.blocks).toEqual([
497
+ { type: "text_final", text: "权威最终文本" },
498
+ ]);
499
+ expect(result!.isFinalResponse).toBe(true);
500
+ });
497
501
 
498
502
  it("result 消息没有 result 字段时返回 null(无可用文本)", () => {
499
503
  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 () => {
@@ -122,7 +122,11 @@ import {
122
122
  _resetResponseStallTimeoutForTest,
123
123
  _setResponseStallCheckIntervalForTest,
124
124
  _resetResponseStallCheckIntervalForTest,
125
+ _setFinalResponseCloseTimeoutForTest,
126
+ _resetFinalResponseCloseTimeoutForTest,
125
127
  setSessionEffortOverride,
128
+ RESPONSE_STALL_RECOVERY_PROMPT,
129
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
126
130
  } from "../session.ts";
127
131
  import {
128
132
  activePrompts,
@@ -369,6 +373,7 @@ describe("runAgentSession process monitor", () => {
369
373
  _resetProcessMonitorIntervalForTest();
370
374
  _resetResponseStallTimeoutForTest();
371
375
  _resetResponseStallCheckIntervalForTest();
376
+ _resetFinalResponseCloseTimeoutForTest();
372
377
  resetBindingState();
373
378
  vi.useRealTimers();
374
379
  });
@@ -670,7 +675,7 @@ describe("runAgentSession process monitor", () => {
670
675
 
671
676
  describe("runAgentSession response stall watchdog", () => {
672
677
  let tempDir = "";
673
- const recoveryPrompt = "完成了吗?如果没完成继续";
678
+ const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
674
679
 
675
680
  beforeEach(async () => {
676
681
  vi.useFakeTimers();
@@ -690,6 +695,7 @@ describe("runAgentSession response stall watchdog", () => {
690
695
  _resetProcessAliveForTest();
691
696
  _resetResponseStallTimeoutForTest();
692
697
  _resetResponseStallCheckIntervalForTest();
698
+ _resetFinalResponseCloseTimeoutForTest();
693
699
  resetBindingState();
694
700
  vi.useRealTimers();
695
701
  if (tempDir) await rm(tempDir, { recursive: true, force: true });
@@ -768,6 +774,290 @@ describe("runAgentSession response stall watchdog", () => {
768
774
  });
769
775
  });
770
776
 
777
+ it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
778
+ vi.setSystemTime(0);
779
+ _setResponseStallTimeoutForTest(100);
780
+ _setResponseStallCheckIntervalForTest(10);
781
+ _setProcessAliveForTest(() => true);
782
+
783
+ const platform = mockPlatform("feishu");
784
+ setSessionPlatform(platform);
785
+
786
+ const receivedPrompts: string[] = [];
787
+ const adapter: ToolAdapter = {
788
+ displayName: "Any Agent",
789
+ sessionDescPrefix: "Agent Session:",
790
+ createSession: async () => ({ sessionId: "sid-binding-recovery" }),
791
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
792
+ closeSession: async () => {},
793
+ prompt: async function* (
794
+ _sid: string,
795
+ text: string,
796
+ _cwd: string,
797
+ signal?: AbortSignal,
798
+ options?: ToolPromptOptions,
799
+ ) {
800
+ receivedPrompts.push(text);
801
+ options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
802
+ if (receivedPrompts.length === 1) {
803
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
804
+ await new Promise<void>((resolve) => {
805
+ if (signal?.aborted) {
806
+ resolve();
807
+ return;
808
+ }
809
+ signal?.addEventListener("abort", () => resolve(), { once: true });
810
+ });
811
+ return;
812
+ }
813
+ yield {
814
+ type: "assistant",
815
+ blocks: [{ type: "text", text: "recovery completed" }],
816
+ isFinalResponse: true,
817
+ };
818
+ },
819
+ };
820
+ _setAdapterForToolForTest("claude", adapter);
821
+
822
+ const firstRun = runAgentSession(
823
+ "sid-binding-recovery",
824
+ "first prompt",
825
+ platform,
826
+ "chat-binding-recovery",
827
+ 0,
828
+ "claude",
829
+ );
830
+
831
+ await vi.waitFor(() => {
832
+ expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
833
+ });
834
+ const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
835
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
836
+ await firstRun;
837
+ await vi.advanceTimersByTimeAsync(200);
838
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
839
+ await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
840
+
841
+ expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
842
+ expect(platform.cardCreate).toHaveBeenCalledTimes(2);
843
+ expect(platform.cardSend).toHaveBeenCalledTimes(2);
844
+ expect(platform.sendText).toHaveBeenCalledWith(
845
+ "chat-binding-recovery",
846
+ expect.stringContaining(recoveryPrompt),
847
+ );
848
+
849
+ const registry = JSON.parse(
850
+ await readFile(join(tempDir, "session-registry.json"), "utf8"),
851
+ ) as Record<string, { running?: boolean }>;
852
+ expect(registry["chat-binding-recovery"]?.running).toBe(false);
853
+ });
854
+
855
+ it("treats an authoritative final response that races timeout cleanup as completed", async () => {
856
+ vi.setSystemTime(0);
857
+ _setResponseStallTimeoutForTest(100);
858
+ _setResponseStallCheckIntervalForTest(10);
859
+ _setProcessAliveForTest(() => true);
860
+
861
+ const platform = mockPlatform("feishu");
862
+ setSessionPlatform(platform);
863
+ bindChatToSession("sid-final-race", "chat-final-race");
864
+ recordLastActiveChat("sid-final-race", "chat-final-race");
865
+
866
+ const receivedPrompts: string[] = [];
867
+ const adapter: ToolAdapter = {
868
+ displayName: "Any Agent",
869
+ sessionDescPrefix: "Agent Session:",
870
+ createSession: async () => ({ sessionId: "sid-final-race" }),
871
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
872
+ closeSession: async () => {},
873
+ prompt: async function* (
874
+ _sid: string,
875
+ text: string,
876
+ _cwd: string,
877
+ signal?: AbortSignal,
878
+ options?: ToolPromptOptions,
879
+ ) {
880
+ receivedPrompts.push(text);
881
+ options?.onProcessStart?.({ pid: 5151 });
882
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
883
+ await new Promise<void>((resolve) => {
884
+ if (signal?.aborted) {
885
+ resolve();
886
+ return;
887
+ }
888
+ signal?.addEventListener("abort", () => resolve(), { once: true });
889
+ });
890
+ yield {
891
+ type: "assistant",
892
+ blocks: [{ type: "text", text: "completed at the timeout boundary" }],
893
+ isFinalResponse: true,
894
+ };
895
+ },
896
+ };
897
+ _setAdapterForToolForTest("claude", adapter);
898
+
899
+ const run = runAgentSession(
900
+ "sid-final-race",
901
+ "prompt",
902
+ platform,
903
+ "chat-final-race",
904
+ 0,
905
+ "claude",
906
+ );
907
+ await vi.waitFor(() => {
908
+ expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
909
+ });
910
+ const progress = activePrompts.get("sid-final-race")!.responseProgress!;
911
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
912
+ await run;
913
+ await vi.advanceTimersByTimeAsync(1_000);
914
+
915
+ expect(receivedPrompts).toHaveLength(1);
916
+ expect(mockStreamStates.get("sid-final-race")).toMatchObject({
917
+ status: "done",
918
+ finalReply: "completed at the timeout boundary",
919
+ });
920
+ expect(platform.sendText).not.toHaveBeenCalledWith(
921
+ "chat-final-race",
922
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
923
+ );
924
+ });
925
+
926
+ it("force-closes a stream that stays open after an authoritative final event without auto-recovery", async () => {
927
+ vi.setSystemTime(0);
928
+ _setResponseStallTimeoutForTest(100);
929
+ _setResponseStallCheckIntervalForTest(10);
930
+ _setFinalResponseCloseTimeoutForTest(1_000);
931
+ _setProcessAliveForTest(() => true);
932
+
933
+ const platform = mockPlatform("feishu");
934
+ setSessionPlatform(platform);
935
+ bindChatToSession("sid-final-close", "chat-final-close");
936
+ recordLastActiveChat("sid-final-close", "chat-final-close");
937
+
938
+ const closeSession = vi.fn();
939
+ const adapter: ToolAdapter = {
940
+ displayName: "Any Agent",
941
+ sessionDescPrefix: "Agent Session:",
942
+ createSession: async () => ({ sessionId: "sid-final-close" }),
943
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
944
+ closeSession: async () => {},
945
+ prompt: async function* (
946
+ _sid: string,
947
+ _text: string,
948
+ _cwd: string,
949
+ signal?: AbortSignal,
950
+ options?: ToolPromptOptions,
951
+ ) {
952
+ options?.onSessionCreated?.(closeSession);
953
+ options?.onProcessStart?.({ pid: 7171 });
954
+ yield {
955
+ type: "assistant",
956
+ blocks: [{ type: "text", text: "authoritative answer" }],
957
+ };
958
+ yield {
959
+ type: "assistant",
960
+ blocks: [],
961
+ isFinalResponse: true,
962
+ };
963
+ await new Promise<void>((resolve) => {
964
+ if (signal?.aborted) {
965
+ resolve();
966
+ return;
967
+ }
968
+ signal?.addEventListener("abort", () => resolve(), { once: true });
969
+ });
970
+ },
971
+ };
972
+ _setAdapterForToolForTest("claude", adapter);
973
+
974
+ const run = runAgentSession(
975
+ "sid-final-close",
976
+ "prompt",
977
+ platform,
978
+ "chat-final-close",
979
+ 0,
980
+ "claude",
981
+ );
982
+
983
+ await vi.waitFor(() => {
984
+ expect(activePrompts.get("sid-final-close")?.finalResponseObserved).toBe(true);
985
+ });
986
+ await vi.advanceTimersByTimeAsync(900);
987
+ expect(activePrompts.has("sid-final-close")).toBe(true);
988
+
989
+ await vi.advanceTimersByTimeAsync(100);
990
+ await run;
991
+
992
+ expect(closeSession).toHaveBeenCalledTimes(1);
993
+ expect(killProcessTreeMock).toHaveBeenCalledWith(7171);
994
+ expect(mockStreamStates.get("sid-final-close")).toMatchObject({
995
+ status: "done",
996
+ finalReply: "authoritative answer",
997
+ });
998
+ expect(platform.sendText).not.toHaveBeenCalledWith(
999
+ "chat-final-close",
1000
+ expect.stringContaining(RESPONSE_STALL_RECOVERY_PROMPT),
1001
+ );
1002
+ });
1003
+
1004
+ it("cancels the final-response close guard when the stream exits normally", async () => {
1005
+ vi.setSystemTime(0);
1006
+ _setFinalResponseCloseTimeoutForTest(1_000);
1007
+ _setProcessAliveForTest(() => true);
1008
+
1009
+ const platform = mockPlatform("feishu");
1010
+ setSessionPlatform(platform);
1011
+ bindChatToSession("sid-final-normal", "chat-final-normal");
1012
+ recordLastActiveChat("sid-final-normal", "chat-final-normal");
1013
+
1014
+ const closeSession = vi.fn();
1015
+ const adapter: ToolAdapter = {
1016
+ displayName: "Any Agent",
1017
+ sessionDescPrefix: "Agent Session:",
1018
+ createSession: async () => ({ sessionId: "sid-final-normal" }),
1019
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1020
+ closeSession: async () => {},
1021
+ prompt: async function* (
1022
+ _sid: string,
1023
+ _text: string,
1024
+ _cwd: string,
1025
+ _signal?: AbortSignal,
1026
+ options?: ToolPromptOptions,
1027
+ ) {
1028
+ options?.onSessionCreated?.(closeSession);
1029
+ options?.onProcessStart?.({ pid: 7272 });
1030
+ yield {
1031
+ type: "assistant",
1032
+ blocks: [{ type: "text", text: "normal answer" }],
1033
+ };
1034
+ yield {
1035
+ type: "assistant",
1036
+ blocks: [],
1037
+ isFinalResponse: true,
1038
+ };
1039
+ },
1040
+ };
1041
+ _setAdapterForToolForTest("claude", adapter);
1042
+
1043
+ await runAgentSession(
1044
+ "sid-final-normal",
1045
+ "prompt",
1046
+ platform,
1047
+ "chat-final-normal",
1048
+ 0,
1049
+ "claude",
1050
+ );
1051
+ await vi.advanceTimersByTimeAsync(2_000);
1052
+
1053
+ expect(closeSession).not.toHaveBeenCalled();
1054
+ expect(killProcessTreeMock).not.toHaveBeenCalled();
1055
+ expect(mockStreamStates.get("sid-final-normal")).toMatchObject({
1056
+ status: "done",
1057
+ finalReply: "normal answer",
1058
+ });
1059
+ });
1060
+
771
1061
  it("runs the reserved recovery prompt before an already queued user message", async () => {
772
1062
  vi.setSystemTime(0);
773
1063
  _setResponseStallTimeoutForTest(100);
@@ -842,7 +1132,7 @@ describe("runAgentSession response stall watchdog", () => {
842
1132
  expect(isSessionRunning("sid-recovery-priority")).toBe(true);
843
1133
  expect(platform.sendText).toHaveBeenCalledWith(
844
1134
  "chat-recovery-priority",
845
- "检测到会话停滞,正在自动确认并继续。",
1135
+ expect.stringContaining(recoveryPrompt),
846
1136
  );
847
1137
 
848
1138
  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
@@ -130,7 +130,8 @@ interface CodexEvent {
130
130
  export function normalizeCodexMessage(
131
131
  msg: CodexEvent,
132
132
  ): UnifiedStreamMessage | null {
133
- // agent_message 文本回复
133
+ // agent_message 只是 Codex 的一条阶段性文本 item。即使内容看起来像完整答复,
134
+ // 后面仍可能继续发出工具调用,因此不能用它关闭 response-stall watchdog。
134
135
  if (
135
136
  msg.type === "item.completed" &&
136
137
  msg.item?.type === "agent_message" &&
@@ -142,6 +143,16 @@ export function normalizeCodexMessage(
142
143
  };
143
144
  }
144
145
 
146
+ // turn.completed 是 Codex 对整轮完成的权威确认。正文已经由之前的
147
+ // agent_message 累计,这里只发送空终态信号,避免重复追加最终文本。
148
+ if (msg.type === "turn.completed") {
149
+ return {
150
+ type: "assistant",
151
+ blocks: [],
152
+ isFinalResponse: true,
153
+ };
154
+ }
155
+
145
156
  // command_execution 工具调用开始
146
157
  if (
147
158
  msg.type === "item.started" &&
@@ -180,7 +191,7 @@ export function normalizeCodexMessage(
180
191
  };
181
192
  }
182
193
 
183
- // thread.started / turn.started / turn.completed → 不映射为用户可见消息
194
+ // thread.started / turn.started → 不映射为用户可见消息
184
195
  return null;
185
196
  }
186
197
 
@@ -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",
@@ -111,6 +111,8 @@ export interface ActivePrompt {
111
111
  processPid?: number;
112
112
  processMonitor?: ReturnType<typeof setInterval>;
113
113
  responseStallMonitor?: ReturnType<typeof setInterval>;
114
+ /** Grace timer that force-closes a stream which stays open after its authoritative final event. */
115
+ finalResponseCloseTimer?: ReturnType<typeof setTimeout>;
114
116
  /** Character-count progress observed only while the activity is "responding". */
115
117
  responseProgress?: ResponseProgressObservation;
116
118
  /** Set before a response-stall auto-end begins so competing monitors cannot win the race. */
@@ -123,6 +125,8 @@ export interface ActivePrompt {
123
125
  resourceStuck?: boolean;
124
126
  /** True only for the single internal continuation turn after a response stall. */
125
127
  autoRecovery?: boolean;
128
+ /** Adapter observed an authoritative completed final-response event. */
129
+ finalResponseObserved?: boolean;
126
130
  /** Adapter-provided callback to close the underlying SDK session / subprocess.
127
131
  * Called by stop-stuck-loop before controller.abort() to terminate the CLI
128
132
  * process immediately, rather than waiting for the async generator to unblock. */
@@ -273,6 +277,7 @@ export function resetBindingState(): void {
273
277
  for (const prompt of activePrompts.values()) {
274
278
  if (prompt.processMonitor) clearInterval(prompt.processMonitor);
275
279
  if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
280
+ if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
276
281
  }
277
282
  activePrompts.clear();
278
283
  finalizingSessions.clear();
package/src/session.ts CHANGED
@@ -165,14 +165,17 @@ function platformForChat(chatId: string): PlatformAdapter | null {
165
165
  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
+ const DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS = 10_000;
168
169
  export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
169
- export const RESPONSE_STALL_RECOVERY_NOTICE = "检测到会话停滞,正在自动确认并继续。";
170
+ export const RESPONSE_STALL_RECOVERY_NOTICE =
171
+ `检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
170
172
  export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
171
173
  "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
172
174
  const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
173
175
  let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
174
176
  let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
175
177
  let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
178
+ let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
176
179
  let isProcessAliveImpl = (pid: number): boolean => {
177
180
  try {
178
181
  process.kill(pid, 0);
@@ -220,7 +223,15 @@ export function _setResponseStallCheckIntervalForTest(ms: number): void {
220
223
  export function _resetResponseStallCheckIntervalForTest(): void {
221
224
  responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
222
225
  }
223
-
226
+
227
+ export function _setFinalResponseCloseTimeoutForTest(ms: number): void {
228
+ finalResponseCloseTimeoutMs = ms;
229
+ }
230
+
231
+ export function _resetFinalResponseCloseTimeoutForTest(): void {
232
+ finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
233
+ }
234
+
224
235
  function clearPromptProcessMonitor(sessionId: string): void {
225
236
  const prompt = activePrompts.get(sessionId);
226
237
  if (!prompt?.processMonitor) return;
@@ -235,6 +246,59 @@ function clearPromptResponseStallMonitor(sessionId: string): void {
235
246
  prompt.responseStallMonitor = undefined;
236
247
  }
237
248
 
249
+ function clearPromptFinalResponseCloseTimer(sessionId: string): void {
250
+ const prompt = activePrompts.get(sessionId);
251
+ if (!prompt?.finalResponseCloseTimer) return;
252
+ clearTimeout(prompt.finalResponseCloseTimer);
253
+ prompt.finalResponseCloseTimer = undefined;
254
+ }
255
+
256
+ /**
257
+ * 权威终态只说明 Agent 已完成本轮,不保证 CLI/SDK 的输出流会及时关闭。
258
+ * 给正常清理保留 10 秒;若流仍悬挂,则关闭底层 session 并杀掉当前 CLI 树,
259
+ * 让 runAgentSession 以 done 收尾。这里绝不触发自动续跑,因为答案已完整到达。
260
+ */
261
+ function scheduleFinalResponseCloseGuard(
262
+ sessionId: string,
263
+ runningPrompt: NonNullable<ReturnType<typeof activePrompts.get>>,
264
+ ): void {
265
+ if (runningPrompt.finalResponseCloseTimer) return;
266
+
267
+ const timeoutMs = finalResponseCloseTimeoutMs;
268
+ const handle = setTimeout(() => {
269
+ const current = activePrompts.get(sessionId);
270
+ if (
271
+ !current
272
+ || current !== runningPrompt
273
+ || !current.finalResponseObserved
274
+ || current.stopped
275
+ || current.abnormalExit
276
+ || current.resourceStuck
277
+ || current.autoEnded
278
+ ) {
279
+ return;
280
+ }
281
+
282
+ current.finalResponseCloseTimer = undefined;
283
+ clearPromptProcessMonitor(sessionId);
284
+ clearPromptResponseStallMonitor(sessionId);
285
+ try {
286
+ current.closeSession?.();
287
+ } catch (err) {
288
+ console.warn(
289
+ `[${ts()}] [FINAL-RESPONSE] closeSession failed for ${sessionId}: ${(err as Error).message}`,
290
+ );
291
+ }
292
+ current.controller.abort();
293
+ void killProcessTree(current.processPid);
294
+ console.warn(
295
+ `[${ts()}] [FINAL-RESPONSE] Session ${sessionId} stream stayed open for ${timeoutMs}ms after its authoritative final event; forced clean shutdown`,
296
+ );
297
+ }, timeoutMs);
298
+ handle.unref?.();
299
+ runningPrompt.finalResponseCloseTimer = handle;
300
+ }
301
+
238
302
  function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
239
303
  title: string;
240
304
  template?: string;
@@ -396,6 +460,7 @@ export function resetState(): void {
396
460
  for (const prompt of activePrompts.values()) {
397
461
  if (prompt.processMonitor) clearInterval(prompt.processMonitor);
398
462
  if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
463
+ if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
399
464
  }
400
465
  activePrompts.clear();
401
466
  displayCards.clear();
@@ -997,9 +1062,19 @@ export async function runAgentSession(
997
1062
  traceId?: string,
998
1063
  options: RunAgentSessionOptions = {},
999
1064
  ): Promise<void> {
1000
- const tid = traceId ?? "";
1001
-
1002
- // 记录用户最后发送消息的群(display loop 只推送到该群)
1065
+ const tid = traceId ?? "";
1066
+
1067
+ // runAgentSession 是飞书用户消息、队列消息与内部自动恢复共同使用的唯一
1068
+ // prompt 执行入口。即使冷启动后的历史群只靠群描述解析出 sessionId、registry
1069
+ // 尚未重建出内存映射,也必须在任何异步操作前补齐绑定,确保三种来源都有完全
1070
+ // 相同的卡片、状态和收尾行为。
1071
+ const previousSessionId = sessionInfoMap.get(_chatId)?.sessionId;
1072
+ if (previousSessionId && previousSessionId !== sessionId) {
1073
+ unbindChatFromSession(previousSessionId, _chatId);
1074
+ }
1075
+ bindChatToSession(sessionId, _chatId);
1076
+
1077
+ // 记录用户最后发送消息的群(display loop 只推送到该群)
1003
1078
  // 如果是从队列消费且队列消息来自其他群,保留原来的 display chat
1004
1079
  recordChatPlatform(_chatId, platform);
1005
1080
  recordLastActiveChat(sessionId, consumeQueuePreservedChat(sessionId) ?? _chatId);
@@ -1025,6 +1100,7 @@ export async function runAgentSession(
1025
1100
  stopped: false,
1026
1101
  startTime: now,
1027
1102
  autoRecovery: options.autoRecovery === true,
1103
+ finalResponseObserved: false,
1028
1104
  });
1029
1105
 
1030
1106
  // 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
@@ -1269,6 +1345,7 @@ export async function runAgentSession(
1269
1345
  || current.abnormalExit
1270
1346
  || current.resourceStuck
1271
1347
  || current.autoEnded
1348
+ || current.finalResponseObserved
1272
1349
  || activityTracker.activity.kind !== "responding"
1273
1350
  || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
1274
1351
  ) {
@@ -1305,6 +1382,18 @@ export async function runAgentSession(
1305
1382
  autoEndedAt,
1306
1383
  });
1307
1384
 
1385
+ // 最终事件可能在上面的落盘 I/O 期间到达。只有适配器明确标记的完整
1386
+ // final response 才能赢得这场竞态;普通文本片段绝不能取消超时。
1387
+ if (current.finalResponseObserved) {
1388
+ current.autoEnded = false;
1389
+ current.autoEndedAt = undefined;
1390
+ cancelAutoRecoveryReservation(sessionId);
1391
+ console.log(
1392
+ `[${ts()}] [RESPONSE-STALL] Authoritative final response won timeout race for ${sessionId}`,
1393
+ );
1394
+ return;
1395
+ }
1396
+
1308
1397
  try {
1309
1398
  current.closeSession?.();
1310
1399
  } catch (err) {
@@ -1336,11 +1425,23 @@ export async function runAgentSession(
1336
1425
  clearPromptProcessMonitor(sessionId);
1337
1426
  if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
1338
1427
  },
1339
- onSessionCreated: (closeSession) => {
1340
- const prompt = activePrompts.get(sessionId);
1341
- if (prompt) prompt.closeSession = closeSession;
1342
- },
1343
- })) {
1428
+ onSessionCreated: (closeSession) => {
1429
+ const prompt = activePrompts.get(sessionId);
1430
+ if (prompt) prompt.closeSession = closeSession;
1431
+ },
1432
+ })) {
1433
+ if (unifiedMsg.isFinalResponse) {
1434
+ const prompt = activePrompts.get(sessionId);
1435
+ if (prompt && prompt === runningPrompt) {
1436
+ // 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
1437
+ // 最终事件后仍把本轮判为停滞。
1438
+ if (!prompt.finalResponseObserved) {
1439
+ prompt.finalResponseObserved = true;
1440
+ scheduleFinalResponseCloseGuard(sessionId, prompt);
1441
+ }
1442
+ }
1443
+ }
1444
+
1344
1445
  let activityChanged = false;
1345
1446
  for (const block of unifiedMsg.blocks) {
1346
1447
  if (updateAgentActivity(activityTracker, block)) activityChanged = true;
@@ -1401,27 +1502,47 @@ export async function runAgentSession(
1401
1502
  const wasStopped = prompt?.stopped ?? false;
1402
1503
  const wasAbnormalExit = prompt?.abnormalExit ?? false;
1403
1504
  const wasResourceStuck = prompt?.resourceStuck ?? false;
1404
- const wasAutoEnded = prompt?.autoEnded ?? false;
1505
+ const timeoutTriggered = prompt?.autoEnded ?? false;
1506
+ const completedAtTimeoutBoundary =
1507
+ timeoutTriggered && (prompt?.finalResponseObserved ?? false);
1508
+ const wasAutoEnded = timeoutTriggered && !completedAtTimeoutBoundary;
1405
1509
  const wasAutoRecovery = prompt?.autoRecovery ?? false;
1406
- const autoEndedAt = prompt?.autoEndedAt;
1510
+ const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1407
1511
  clearPromptResponseStallMonitor(sessionId);
1408
1512
  clearPromptProcessMonitor(sessionId);
1513
+ clearPromptFinalResponseCloseTimer(sessionId);
1409
1514
  markSessionFinalizing(sessionId);
1410
1515
  activePrompts.delete(sessionId);
1411
1516
 
1412
1517
  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();
1518
+ if (completedAtTimeoutBoundary) {
1519
+ // reservation 可能在 watchdog 开始终止普通轮时已经建立。最终回复若在
1520
+ // abort/kill 清理边界到达,本轮按完成处理,并原子取消尚未启动的恢复轮。
1521
+ cancelAutoRecoveryReservation(sessionId);
1522
+ console.log(
1523
+ `[${ts()}] [RESPONSE-STALL] Session ${sessionId} completed with an authoritative final response during timeout cleanup`,
1524
+ );
1525
+ }
1526
+ // 即使运行期间映射被异常清空,也必须更新本次实际触发 chat,避免 registry
1527
+ // 永久残留 running=true。
1528
+ const finalizationChatIds = [...new Set([
1529
+ ...getChatsForSession(sessionId),
1530
+ _chatId,
1531
+ ])];
1532
+ // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1533
+ // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1534
+ // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1535
+ // 运行中并更新旧卡片,而不是新建卡片。
1536
+ const finalStatus = completedAtTimeoutBoundary
1537
+ ? "done"
1538
+ : wasAutoEnded
1539
+ ? "auto_ended"
1540
+ : (streamErrored || wasAbnormalExit || wasResourceStuck)
1541
+ ? "error"
1542
+ : wasStopped
1543
+ ? "stopped"
1544
+ : "done";
1545
+ const finalReply = pickFinalReply(state).trim();
1425
1546
 
1426
1547
  // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的
1427
1548
  // stream state,finally 不应覆盖它。同时保留 stuckAt 标记,防止
@@ -1459,7 +1580,7 @@ export async function runAgentSession(
1459
1580
  let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
1460
1581
 
1461
1582
  if (wasStopped) {
1462
- for (const cid of getChatsForSession(sessionId)) {
1583
+ for (const cid of finalizationChatIds) {
1463
1584
  const finfo = sessionInfoMap.get(cid);
1464
1585
  await recordSessionRegistry({
1465
1586
  chatId: cid,
@@ -1471,7 +1592,7 @@ export async function runAgentSession(
1471
1592
  running: false,
1472
1593
  });
1473
1594
  }
1474
- const active1 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1595
+ const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1475
1596
  if (active1) {
1476
1597
  await platform.sendText(active1, "会话已停止。").catch(() => {});
1477
1598
  platform.setChatAvatar(active1, tool, "idle").catch(() => {});
@@ -1479,7 +1600,7 @@ export async function runAgentSession(
1479
1600
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1480
1601
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1481
1602
  } else if (wasAutoEnded) {
1482
- for (const cid of getChatsForSession(sessionId)) {
1603
+ for (const cid of finalizationChatIds) {
1483
1604
  const finfo = sessionInfoMap.get(cid);
1484
1605
  await recordSessionRegistry({
1485
1606
  chatId: cid,
@@ -1491,7 +1612,7 @@ export async function runAgentSession(
1491
1612
  running: false,
1492
1613
  });
1493
1614
  }
1494
- const activeAutoEnded = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1615
+ const activeAutoEnded = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1495
1616
  if (activeAutoEnded) {
1496
1617
  const pp = platformForChat(activeAutoEnded) ?? platform;
1497
1618
  const terminalState = await readStreamState(sessionId);
@@ -1526,7 +1647,7 @@ export async function runAgentSession(
1526
1647
  console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
1527
1648
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1528
1649
  } else if (wasAbnormalExit) {
1529
- for (const cid of getChatsForSession(sessionId)) {
1650
+ for (const cid of finalizationChatIds) {
1530
1651
  const finfo = sessionInfoMap.get(cid);
1531
1652
  await recordSessionRegistry({
1532
1653
  chatId: cid,
@@ -1538,12 +1659,12 @@ export async function runAgentSession(
1538
1659
  running: false,
1539
1660
  });
1540
1661
  }
1541
- const activeErr = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1662
+ const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1542
1663
  if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
1543
1664
  console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1544
1665
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1545
- } else {
1546
- for (const cid of getChatsForSession(sessionId)) {
1666
+ } else {
1667
+ for (const cid of finalizationChatIds) {
1547
1668
  const finfo = sessionInfoMap.get(cid);
1548
1669
  await recordSessionRegistry({
1549
1670
  chatId: cid,
@@ -1555,7 +1676,7 @@ export async function runAgentSession(
1555
1676
  running: false,
1556
1677
  });
1557
1678
  }
1558
- const active2 = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1679
+ const active2 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1559
1680
  if (active2) {
1560
1681
  const terminalState = await readStreamState(sessionId);
1561
1682
  if (finalReply && !displayCards.has(active2) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
@@ -2027,6 +2148,7 @@ export function stopSession(sessionId: string): boolean {
2027
2148
  prompt.stopped = true;
2028
2149
  clearPromptResponseStallMonitor(sessionId);
2029
2150
  clearPromptProcessMonitor(sessionId);
2151
+ clearPromptFinalResponseCloseTimer(sessionId);
2030
2152
  cancelQueuedMessage(sessionId);
2031
2153
  try {
2032
2154
  prompt.closeSession?.();