chatccc 0.2.223 → 0.2.224

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.
@@ -1,25 +1,25 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
2
  import { mkdtemp, rm, readFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
5
-
6
- const killProcessTreeMock = vi.hoisted(() => vi.fn(async (_pid?: number) => {}));
7
- vi.mock("../adapters/proc-tree-kill.ts", () => ({
8
- killProcessTree: killProcessTreeMock,
9
- }));
10
-
11
- // mock stream-state 以支持在测试中控制累积长度
12
- const mockStreamStates = new Map<string, {
13
- accumulatedContent: string;
14
- finalReply: string;
15
- activity?: {
16
- kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
17
- startedAt: number;
18
- toolName?: string;
19
- toolCount?: number;
20
- };
21
- status?: "running" | "done" | "stopped" | "error" | "auto_ended";
22
- autoEndedAt?: number;
4
+ import { dirname, join } from "node:path";
5
+
6
+ const killProcessTreeMock = vi.hoisted(() => vi.fn(async (_pid?: number) => {}));
7
+ vi.mock("../adapters/proc-tree-kill.ts", () => ({
8
+ killProcessTree: killProcessTreeMock,
9
+ }));
10
+
11
+ // mock stream-state 以支持在测试中控制累积长度
12
+ const mockStreamStates = new Map<string, {
13
+ accumulatedContent: string;
14
+ finalReply: string;
15
+ activity?: {
16
+ kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
17
+ startedAt: number;
18
+ toolName?: string;
19
+ toolCount?: number;
20
+ };
21
+ status?: "running" | "done" | "stopped" | "error" | "auto_ended";
22
+ autoEndedAt?: number;
23
23
  turnCount?: number;
24
24
  finalReplySentTurn?: number;
25
25
  finalReplySentAt?: number;
@@ -30,12 +30,12 @@ vi.mock("../stream-state.ts", () => ({
30
30
  if (!state) return null;
31
31
  return {
32
32
  sessionId: sid,
33
- accumulatedContent: state.accumulatedContent,
34
- finalReply: state.finalReply,
35
- activity: state.activity,
36
- finalReplySentTurn: state.finalReplySentTurn,
37
- finalReplySentAt: state.finalReplySentAt,
38
- autoEndedAt: state.autoEndedAt,
33
+ accumulatedContent: state.accumulatedContent,
34
+ finalReply: state.finalReply,
35
+ activity: state.activity,
36
+ finalReplySentTurn: state.finalReplySentTurn,
37
+ finalReplySentAt: state.finalReplySentAt,
38
+ autoEndedAt: state.autoEndedAt,
39
39
  status: state.status ?? "running",
40
40
  chunkCount: 0,
41
41
  turnCount: state.turnCount ?? 0,
@@ -48,33 +48,33 @@ vi.mock("../stream-state.ts", () => ({
48
48
  writeStreamState: async (state: {
49
49
  sessionId: string;
50
50
  accumulatedContent: string;
51
- finalReply: string;
52
- activity?: {
53
- kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
54
- startedAt: number;
55
- toolName?: string;
56
- toolCount?: number;
57
- };
58
- status?: "running" | "done" | "stopped" | "error" | "auto_ended";
59
- autoEndedAt?: number;
51
+ finalReply: string;
52
+ activity?: {
53
+ kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
54
+ startedAt: number;
55
+ toolName?: string;
56
+ toolCount?: number;
57
+ };
58
+ status?: "running" | "done" | "stopped" | "error" | "auto_ended";
59
+ autoEndedAt?: number;
60
60
  turnCount?: number;
61
61
  finalReplySentTurn?: number;
62
62
  finalReplySentAt?: number;
63
63
  }) => {
64
64
  mockStreamStates.set(state.sessionId, {
65
- accumulatedContent: state.accumulatedContent,
66
- finalReply: state.finalReply,
67
- activity: state.activity,
68
- status: state.status,
69
- autoEndedAt: state.autoEndedAt,
65
+ accumulatedContent: state.accumulatedContent,
66
+ finalReply: state.finalReply,
67
+ activity: state.activity,
68
+ status: state.status,
69
+ autoEndedAt: state.autoEndedAt,
70
70
  turnCount: state.turnCount,
71
71
  finalReplySentTurn: state.finalReplySentTurn,
72
72
  finalReplySentAt: state.finalReplySentAt,
73
73
  });
74
74
  },
75
- createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
76
- sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", activity: { kind: "starting" as const, startedAt: Date.now() }, chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
77
- }),
75
+ createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
76
+ sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", activity: { kind: "starting" as const, startedAt: Date.now() }, chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
77
+ }),
78
78
  isFinalReplySentForTurn: (state: { turnCount: number; finalReplySentTurn?: number }) => state.finalReplySentTurn === state.turnCount,
79
79
  markFinalReplySent: async (sid: string, turnCount: number, sentAt = Date.now()) => {
80
80
  const state = mockStreamStates.get(sid);
@@ -109,29 +109,31 @@ import {
109
109
  _clearAdapterCacheForTest,
110
110
  setSessionPlatform,
111
111
  recordChatPlatform,
112
- _getPlatformForChatForTest,
113
- initClaudeSession,
114
- runAgentSession,
112
+ _getPlatformForChatForTest,
113
+ initClaudeSession,
114
+ runAgentSession,
115
115
  stopSession,
116
116
  startUnifiedDisplayLoop,
117
117
  stopUnifiedDisplayLoop,
118
- _setProcessAliveForTest,
119
- _resetProcessAliveForTest,
120
- _setProcessMonitorIntervalForTest,
121
- _resetProcessMonitorIntervalForTest,
122
- _setResponseStallTimeoutForTest,
123
- _resetResponseStallTimeoutForTest,
124
- _setResponseStallCheckIntervalForTest,
125
- _resetResponseStallCheckIntervalForTest,
126
- _setFinalResponseCloseTimeoutForTest,
127
- _resetFinalResponseCloseTimeoutForTest,
128
- setSessionEffortOverride,
129
- getEffectiveFastModeForTool,
130
- setSessionFastModeOverride,
131
- RESPONSE_STALL_RECOVERY_PROMPT,
132
- RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
133
- } from "../session.ts";
134
- import { config } from "../config.ts";
118
+ _setProcessAliveForTest,
119
+ _resetProcessAliveForTest,
120
+ _setProcessMonitorIntervalForTest,
121
+ _resetProcessMonitorIntervalForTest,
122
+ _setResponseStallTimeoutForTest,
123
+ _resetResponseStallTimeoutForTest,
124
+ _setResponseStallCheckIntervalForTest,
125
+ _resetResponseStallCheckIntervalForTest,
126
+ _setFinalResponseCloseTimeoutForTest,
127
+ _resetFinalResponseCloseTimeoutForTest,
128
+ setSessionEffortOverride,
129
+ clearSessionEffortOverride,
130
+ getEffectiveEffortForTool,
131
+ getEffectiveFastModeForTool,
132
+ setSessionFastModeOverride,
133
+ RESPONSE_STALL_RECOVERY_PROMPT,
134
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
135
+ } from "../session.ts";
136
+ import { config } from "../config.ts";
135
137
  import {
136
138
  activePrompts,
137
139
  bindChatToSession,
@@ -140,34 +142,34 @@ import {
140
142
  getLastActiveChat,
141
143
  pickDisplayChat,
142
144
  resetBindingState,
143
- getChatsForSession,
144
- displayCards,
145
- enqueueMessage,
146
- setQueueConsumer,
147
- isSessionRunning,
148
- } from "../session-chat-binding.ts";
145
+ getChatsForSession,
146
+ displayCards,
147
+ enqueueMessage,
148
+ setQueueConsumer,
149
+ isSessionRunning,
150
+ } from "../session-chat-binding.ts";
149
151
  import type { AccumulatorState } from "../session.ts";
150
152
  import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
151
- import type { PlatformAdapter } from "../platform-adapter.ts";
152
-
153
- describe("Codex Fast mode overrides", () => {
154
- it("uses the global default until the current session overrides it", () => {
155
- const previousFastMode = config.codex.fastMode;
156
- config.codex.fastMode = false;
157
- resetState();
158
-
159
- expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
160
- setSessionFastModeOverride("sid-fast", true);
161
- expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(true);
162
- setSessionFastModeOverride("sid-fast", false);
163
- expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
164
- expect(getEffectiveFastModeForTool("claude", "sid-fast")).toBe(false);
165
-
166
- resetState();
167
- expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
168
- config.codex.fastMode = previousFastMode;
169
- });
170
- });
153
+ import type { PlatformAdapter } from "../platform-adapter.ts";
154
+
155
+ describe("Codex Fast mode overrides", () => {
156
+ it("uses the global default until the current session overrides it", () => {
157
+ const previousFastMode = config.codex.fastMode;
158
+ config.codex.fastMode = false;
159
+ resetState();
160
+
161
+ expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
162
+ setSessionFastModeOverride("sid-fast", true);
163
+ expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(true);
164
+ setSessionFastModeOverride("sid-fast", false);
165
+ expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
166
+ expect(getEffectiveFastModeForTool("claude", "sid-fast")).toBe(false);
167
+
168
+ resetState();
169
+ expect(getEffectiveFastModeForTool("codex", "sid-fast")).toBe(false);
170
+ config.codex.fastMode = previousFastMode;
171
+ });
172
+ });
171
173
 
172
174
  // Helper to create a mock active session entry
173
175
  function mockActiveSession(chatId: string, overrides: Partial<{
@@ -249,7 +251,7 @@ function mockPlatform(name: string): PlatformAdapter {
249
251
  };
250
252
  }
251
253
 
252
- describe("resetState", () => {
254
+ describe("resetState", () => {
253
255
  it("clears all maps and sets", () => {
254
256
  chatSessionMap.set("chat1", {
255
257
  gen: 1, close: () => {}, cardId: null, stopped: false,
@@ -268,58 +270,58 @@ describe("resetState", () => {
268
270
  expect(sessionInfoMap.size).toBe(0);
269
271
  expect(processedMessages.size).toBe(0);
270
272
  });
271
- });
272
-
273
- describe("initClaudeSession startup watchdog", () => {
274
- afterEach(() => {
275
- _clearAdapterCacheForTest();
276
- _resetResponseStallTimeoutForTest();
277
- vi.useRealTimers();
278
- });
279
-
280
- it("aborts session creation when an Agent never emits its init event", async () => {
281
- vi.useFakeTimers();
282
- _setResponseStallTimeoutForTest(100);
283
-
284
- let aborted = false;
285
- const adapter: ToolAdapter = {
286
- displayName: "Silent Cursor",
287
- sessionDescPrefix: "Cursor Session:",
288
- createSession: async (_cwd: string, signal?: AbortSignal) => {
289
- await new Promise<void>((resolve) => {
290
- if (signal?.aborted) {
291
- aborted = true;
292
- resolve();
293
- return;
294
- }
295
- signal?.addEventListener("abort", () => {
296
- aborted = true;
297
- resolve();
298
- }, { once: true });
299
- });
300
- throw new Error("adapter aborted");
301
- },
302
- prompt: async function* () {},
303
- getSessionInfo: async () => undefined,
304
- closeSession: async () => {},
305
- };
306
- _setAdapterForToolForTest("cursor", adapter);
307
-
308
- let outcome: unknown;
309
- void initClaudeSession("cursor", "F:\\repo").then(
310
- (value) => { outcome = value; },
311
- (err) => { outcome = err; },
312
- );
313
-
314
- await vi.advanceTimersByTimeAsync(101);
315
-
316
- expect(aborted).toBe(true);
317
- expect(outcome).toBeInstanceOf(Error);
318
- expect((outcome as Error).message).toContain("3 minutes");
319
- });
320
- });
321
-
322
- describe("chat platform routing", () => {
273
+ });
274
+
275
+ describe("initClaudeSession startup watchdog", () => {
276
+ afterEach(() => {
277
+ _clearAdapterCacheForTest();
278
+ _resetResponseStallTimeoutForTest();
279
+ vi.useRealTimers();
280
+ });
281
+
282
+ it("aborts session creation when an Agent never emits its init event", async () => {
283
+ vi.useFakeTimers();
284
+ _setResponseStallTimeoutForTest(100);
285
+
286
+ let aborted = false;
287
+ const adapter: ToolAdapter = {
288
+ displayName: "Silent Cursor",
289
+ sessionDescPrefix: "Cursor Session:",
290
+ createSession: async (_cwd: string, signal?: AbortSignal) => {
291
+ await new Promise<void>((resolve) => {
292
+ if (signal?.aborted) {
293
+ aborted = true;
294
+ resolve();
295
+ return;
296
+ }
297
+ signal?.addEventListener("abort", () => {
298
+ aborted = true;
299
+ resolve();
300
+ }, { once: true });
301
+ });
302
+ throw new Error("adapter aborted");
303
+ },
304
+ prompt: async function* () {},
305
+ getSessionInfo: async () => undefined,
306
+ closeSession: async () => {},
307
+ };
308
+ _setAdapterForToolForTest("cursor", adapter);
309
+
310
+ let outcome: unknown;
311
+ void initClaudeSession("cursor", "F:\\repo").then(
312
+ (value) => { outcome = value; },
313
+ (err) => { outcome = err; },
314
+ );
315
+
316
+ await vi.advanceTimersByTimeAsync(101);
317
+
318
+ expect(aborted).toBe(true);
319
+ expect(outcome).toBeInstanceOf(Error);
320
+ expect((outcome as Error).message).toContain("3 minutes");
321
+ });
322
+ });
323
+
324
+ describe("chat platform routing", () => {
323
325
  beforeEach(() => {
324
326
  resetState();
325
327
  });
@@ -441,16 +443,16 @@ describe("runAgentSession process monitor", () => {
441
443
  _resetSessionRegistryFileForTest();
442
444
  _resetSessionToolsFileForTest();
443
445
  _clearAdapterCacheForTest();
444
- _resetProcessAliveForTest();
445
- _resetProcessMonitorIntervalForTest();
446
- _resetResponseStallTimeoutForTest();
447
- _resetResponseStallCheckIntervalForTest();
448
- _resetFinalResponseCloseTimeoutForTest();
446
+ _resetProcessAliveForTest();
447
+ _resetProcessMonitorIntervalForTest();
448
+ _resetResponseStallTimeoutForTest();
449
+ _resetResponseStallCheckIntervalForTest();
450
+ _resetFinalResponseCloseTimeoutForTest();
449
451
  resetBindingState();
450
452
  vi.useRealTimers();
451
453
  });
452
454
 
453
- it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
455
+ it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
454
456
  const platform = mockPlatform("feishu");
455
457
  setSessionPlatform(platform);
456
458
  bindChatToSession("sid-process", "chat-process");
@@ -545,7 +547,7 @@ describe("runAgentSession process monitor", () => {
545
547
  );
546
548
  });
547
549
 
548
- it("does not register an invisible progress card and sends the final text fallback", async () => {
550
+ it("does not register an invisible progress card and sends the final text fallback", async () => {
549
551
  vi.spyOn(console, "error").mockImplementation(() => {});
550
552
  const platform = mockPlatform("feishu");
551
553
  platform.cardCreate = vi.fn()
@@ -578,77 +580,77 @@ describe("runAgentSession process monitor", () => {
578
580
  "生成中卡片发送失败,结果将以文本形式发送。",
579
581
  );
580
582
  expect(platform.sendText).toHaveBeenCalledWith("chat-card-fallback", "final answer");
581
- expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
582
- });
583
-
584
- it("consumes a queued message only after the previous turn finishes final delivery", async () => {
585
- const platform = mockPlatform("feishu");
586
- setSessionPlatform(platform);
587
- bindChatToSession("sid-queue-finalize", "chat-queue-finalize");
588
- recordLastActiveChat("sid-queue-finalize", "chat-queue-finalize");
589
-
590
- let releaseFinalDelivery: (() => void) | undefined;
591
- const finalDeliveryGate = new Promise<void>((resolve) => {
592
- releaseFinalDelivery = resolve;
593
- });
594
- platform.sendText = vi.fn(async (_chatId, content) => {
595
- if (content === "final answer") await finalDeliveryGate;
596
- return true;
597
- });
598
-
599
- const adapter: ToolAdapter = {
600
- displayName: "Claude Code",
601
- sessionDescPrefix: "Claude Code Session:",
602
- createSession: async () => ({ sessionId: "sid-queue-finalize" }),
603
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
604
- closeSession: async () => {},
605
- prompt: async function* () {
606
- // 强制走最终文本发送路径,并用 gate 模拟该收尾步骤仍在进行。
607
- displayCards.delete("chat-queue-finalize");
608
- yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
609
- },
610
- };
611
- _setAdapterForToolForTest("claude", adapter);
612
-
613
- enqueueMessage("sid-queue-finalize", {
614
- text: "queued prompt",
615
- chatId: "chat-queue-finalize",
616
- openId: "open-user",
617
- msgTimestamp: Date.now(),
618
- chatType: "p2p",
619
- });
620
- const consumeQueued = vi.fn();
621
- setQueueConsumer(consumeQueued);
622
-
623
- const runPromise = runAgentSession(
624
- "sid-queue-finalize",
625
- "first prompt",
626
- platform,
627
- "chat-queue-finalize",
628
- Date.now(),
629
- "claude",
630
- );
631
- await vi.waitFor(() => {
632
- expect(platform.sendText).toHaveBeenCalledWith("chat-queue-finalize", "final answer");
633
- });
634
- expect(isSessionRunning("sid-queue-finalize")).toBe(true);
635
-
636
- await vi.advanceTimersByTimeAsync(250);
637
- expect(consumeQueued).not.toHaveBeenCalled();
638
-
639
- releaseFinalDelivery?.();
640
- await runPromise;
641
- expect(isSessionRunning("sid-queue-finalize")).toBe(false);
642
- await vi.advanceTimersByTimeAsync(200);
643
- expect(consumeQueued).toHaveBeenCalledTimes(1);
644
- expect(consumeQueued).toHaveBeenCalledWith(
645
- platform,
646
- expect.objectContaining({ text: "queued prompt", chatId: "chat-queue-finalize" }),
647
- );
648
- setQueueConsumer(() => {});
649
- });
650
-
651
- it("sends the stopped notice only after the prompt generator exits", async () => {
583
+ expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
584
+ });
585
+
586
+ it("consumes a queued message only after the previous turn finishes final delivery", async () => {
587
+ const platform = mockPlatform("feishu");
588
+ setSessionPlatform(platform);
589
+ bindChatToSession("sid-queue-finalize", "chat-queue-finalize");
590
+ recordLastActiveChat("sid-queue-finalize", "chat-queue-finalize");
591
+
592
+ let releaseFinalDelivery: (() => void) | undefined;
593
+ const finalDeliveryGate = new Promise<void>((resolve) => {
594
+ releaseFinalDelivery = resolve;
595
+ });
596
+ platform.sendText = vi.fn(async (_chatId, content) => {
597
+ if (content === "final answer") await finalDeliveryGate;
598
+ return true;
599
+ });
600
+
601
+ const adapter: ToolAdapter = {
602
+ displayName: "Claude Code",
603
+ sessionDescPrefix: "Claude Code Session:",
604
+ createSession: async () => ({ sessionId: "sid-queue-finalize" }),
605
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
606
+ closeSession: async () => {},
607
+ prompt: async function* () {
608
+ // 强制走最终文本发送路径,并用 gate 模拟该收尾步骤仍在进行。
609
+ displayCards.delete("chat-queue-finalize");
610
+ yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
611
+ },
612
+ };
613
+ _setAdapterForToolForTest("claude", adapter);
614
+
615
+ enqueueMessage("sid-queue-finalize", {
616
+ text: "queued prompt",
617
+ chatId: "chat-queue-finalize",
618
+ openId: "open-user",
619
+ msgTimestamp: Date.now(),
620
+ chatType: "p2p",
621
+ });
622
+ const consumeQueued = vi.fn();
623
+ setQueueConsumer(consumeQueued);
624
+
625
+ const runPromise = runAgentSession(
626
+ "sid-queue-finalize",
627
+ "first prompt",
628
+ platform,
629
+ "chat-queue-finalize",
630
+ Date.now(),
631
+ "claude",
632
+ );
633
+ await vi.waitFor(() => {
634
+ expect(platform.sendText).toHaveBeenCalledWith("chat-queue-finalize", "final answer");
635
+ });
636
+ expect(isSessionRunning("sid-queue-finalize")).toBe(true);
637
+
638
+ await vi.advanceTimersByTimeAsync(250);
639
+ expect(consumeQueued).not.toHaveBeenCalled();
640
+
641
+ releaseFinalDelivery?.();
642
+ await runPromise;
643
+ expect(isSessionRunning("sid-queue-finalize")).toBe(false);
644
+ await vi.advanceTimersByTimeAsync(200);
645
+ expect(consumeQueued).toHaveBeenCalledTimes(1);
646
+ expect(consumeQueued).toHaveBeenCalledWith(
647
+ platform,
648
+ expect.objectContaining({ text: "queued prompt", chatId: "chat-queue-finalize" }),
649
+ );
650
+ setQueueConsumer(() => {});
651
+ });
652
+
653
+ it("sends the stopped notice only after the prompt generator exits", async () => {
652
654
  const platform = mockPlatform("feishu");
653
655
  setSessionPlatform(platform);
654
656
  bindChatToSession("sid-stop-notice", "chat-stop-notice");
@@ -745,947 +747,947 @@ describe("runAgentSession process monitor", () => {
745
747
  });
746
748
  });
747
749
 
748
- describe("runAgentSession response stall watchdog", () => {
749
- let tempDir = "";
750
- const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
751
-
752
- beforeEach(async () => {
753
- vi.useFakeTimers();
754
- resetState();
755
- resetBindingState();
756
- mockStreamStates.clear();
757
- killProcessTreeMock.mockClear();
758
- tempDir = await mkdtemp(join(tmpdir(), "chatccc-response-stall-"));
759
- _setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
760
- _setSessionToolsFileForTest(join(tempDir, "session-tools.json"));
761
- });
762
-
763
- afterEach(async () => {
764
- _resetSessionRegistryFileForTest();
765
- _resetSessionToolsFileForTest();
766
- _clearAdapterCacheForTest();
767
- _resetProcessAliveForTest();
768
- _resetResponseStallTimeoutForTest();
769
- _resetResponseStallCheckIntervalForTest();
770
- _resetFinalResponseCloseTimeoutForTest();
771
- resetBindingState();
772
- vi.useRealTimers();
773
- if (tempDir) await rm(tempDir, { recursive: true, force: true });
774
- });
775
-
776
- it("auto-ends every Agent after three minutes of unchanged reply characters, including zero", async () => {
777
- vi.setSystemTime(0);
778
- _setResponseStallTimeoutForTest(180_000);
779
- _setResponseStallCheckIntervalForTest(1_000);
780
- _setProcessAliveForTest(() => true);
781
-
782
- const platform = mockPlatform("feishu");
783
- setSessionPlatform(platform);
784
- bindChatToSession("sid-response-stall", "chat-response-stall");
785
- recordLastActiveChat("sid-response-stall", "chat-response-stall");
786
-
787
- const closeSession = vi.fn();
788
- const adapter: ToolAdapter = {
789
- displayName: "Any Agent",
790
- sessionDescPrefix: "Agent Session:",
791
- createSession: async () => ({ sessionId: "sid-response-stall" }),
792
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
793
- closeSession: async () => {},
794
- prompt: async function* (
795
- _sid: string,
796
- _text: string,
797
- _cwd: string,
798
- signal?: AbortSignal,
799
- options?: ToolPromptOptions,
800
- ) {
801
- options?.onSessionCreated?.(closeSession);
802
- options?.onProcessStart?.({ pid: 4242 });
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
- },
812
- };
813
- _setAdapterForToolForTest("claude", adapter);
814
-
815
- const runPromise = runAgentSession(
816
- "sid-response-stall",
817
- "prompt",
818
- platform,
819
- "chat-response-stall",
820
- Date.now(),
821
- "claude",
822
- );
823
-
824
- await vi.waitFor(() => {
825
- expect(activePrompts.get("sid-response-stall")?.responseProgress).toEqual({
826
- totalChars: 0,
827
- unchangedSince: expect.any(Number),
828
- });
829
- });
830
-
831
- const progress = activePrompts.get("sid-response-stall")!.responseProgress!;
832
- const remainingBeforeBoundary = progress.unchangedSince + 180_000 - Date.now() - 1;
833
- await vi.advanceTimersByTimeAsync(remainingBeforeBoundary);
834
- expect(activePrompts.has("sid-response-stall")).toBe(true);
835
-
836
- await vi.advanceTimersByTimeAsync(1_001);
837
- await runPromise;
838
-
839
- expect(closeSession).toHaveBeenCalledTimes(1);
840
- expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
841
- expect(activePrompts.has("sid-response-stall")).toBe(false);
842
- expect(mockStreamStates.get("sid-response-stall")).toMatchObject({
843
- status: "auto_ended",
844
- finalReply: "",
845
- autoEndedAt: expect.any(Number),
846
- });
847
- });
848
-
849
- it("auto-ends an Agent that stays in starting state without emitting any event", async () => {
850
- vi.setSystemTime(0);
851
- _setResponseStallTimeoutForTest(180_000);
852
- _setResponseStallCheckIntervalForTest(1_000);
853
- _setProcessAliveForTest(() => true);
854
-
855
- const platform = mockPlatform("feishu");
856
- setSessionPlatform(platform);
857
- bindChatToSession("sid-starting-stall", "chat-starting-stall");
858
- recordLastActiveChat("sid-starting-stall", "chat-starting-stall");
859
-
860
- const closeSession = vi.fn();
861
- const adapter: ToolAdapter = {
862
- displayName: "Silent Agent",
863
- sessionDescPrefix: "Agent Session:",
864
- createSession: async () => ({ sessionId: "sid-starting-stall" }),
865
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
866
- closeSession: async () => {},
867
- prompt: async function* (
868
- _sid: string,
869
- _text: string,
870
- _cwd: string,
871
- signal?: AbortSignal,
872
- options?: ToolPromptOptions,
873
- ) {
874
- options?.onSessionCreated?.(closeSession);
875
- options?.onProcessStart?.({ pid: 4343 });
876
- await new Promise<void>((resolve) => {
877
- if (signal?.aborted) {
878
- resolve();
879
- return;
880
- }
881
- signal?.addEventListener("abort", () => resolve(), { once: true });
882
- });
883
- if (false) yield { type: "assistant", blocks: [] };
884
- },
885
- };
886
- _setAdapterForToolForTest("codex", adapter);
887
-
888
- const runPromise = runAgentSession(
889
- "sid-starting-stall",
890
- "prompt",
891
- platform,
892
- "chat-starting-stall",
893
- Date.now(),
894
- "codex",
895
- );
896
-
897
- await vi.waitFor(() => {
898
- expect(activePrompts.get("sid-starting-stall")).toMatchObject({
899
- processPid: 4343,
900
- responseProgress: {
901
- totalChars: 0,
902
- unchangedSince: expect.any(Number),
903
- },
904
- });
905
- expect(closeSession).toHaveBeenCalledTimes(0);
906
- });
907
-
908
- await vi.advanceTimersByTimeAsync(181_001);
909
- const stateAfterDeadline = mockStreamStates.get("sid-starting-stall");
910
- const closeCountAfterDeadline = closeSession.mock.calls.length;
911
- const treeKillCountAfterDeadline = killProcessTreeMock.mock.calls
912
- .filter(([pid]) => pid === 4343)
913
- .length;
914
-
915
- // 旧实现不会结束真正的零事件启动;先清理测试会话,避免失败用例悬挂。
916
- if (stateAfterDeadline?.status !== "auto_ended") {
917
- stopSession("sid-starting-stall");
918
- await vi.advanceTimersByTimeAsync(1_000);
919
- }
920
- await runPromise;
921
-
922
- expect(stateAfterDeadline).toMatchObject({
923
- status: "auto_ended",
924
- finalReply: "",
925
- autoEndedAt: expect.any(Number),
926
- });
927
- expect(closeCountAfterDeadline).toBe(1);
928
- expect(treeKillCountAfterDeadline).toBeGreaterThanOrEqual(1);
929
- });
930
-
931
- it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
932
- vi.setSystemTime(0);
933
- _setResponseStallTimeoutForTest(100);
934
- _setResponseStallCheckIntervalForTest(10);
935
- _setProcessAliveForTest(() => true);
936
-
937
- const platform = mockPlatform("feishu");
938
- setSessionPlatform(platform);
939
-
940
- const receivedPrompts: string[] = [];
941
- const adapter: ToolAdapter = {
942
- displayName: "Any Agent",
943
- sessionDescPrefix: "Agent Session:",
944
- createSession: async () => ({ sessionId: "sid-binding-recovery" }),
945
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
946
- closeSession: async () => {},
947
- prompt: async function* (
948
- _sid: string,
949
- text: string,
950
- _cwd: string,
951
- signal?: AbortSignal,
952
- options?: ToolPromptOptions,
953
- ) {
954
- receivedPrompts.push(text);
955
- options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
956
- if (receivedPrompts.length === 1) {
957
- yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
958
- await new Promise<void>((resolve) => {
959
- if (signal?.aborted) {
960
- resolve();
961
- return;
962
- }
963
- signal?.addEventListener("abort", () => resolve(), { once: true });
964
- });
965
- return;
966
- }
967
- yield {
968
- type: "assistant",
969
- blocks: [{ type: "text", text: "recovery completed" }],
970
- isFinalResponse: true,
971
- };
972
- },
973
- };
974
- _setAdapterForToolForTest("claude", adapter);
975
-
976
- const firstRun = runAgentSession(
977
- "sid-binding-recovery",
978
- "first prompt",
979
- platform,
980
- "chat-binding-recovery",
981
- 0,
982
- "claude",
983
- );
984
-
985
- await vi.waitFor(() => {
986
- expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
987
- });
988
- const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
989
- await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
990
- await firstRun;
991
- await vi.advanceTimersByTimeAsync(200);
992
- await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
993
- await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
994
-
995
- expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
996
- expect(platform.cardCreate).toHaveBeenCalledTimes(2);
997
- expect(platform.cardSend).toHaveBeenCalledTimes(2);
998
- expect(platform.sendText).toHaveBeenCalledWith(
999
- "chat-binding-recovery",
1000
- expect.stringContaining(recoveryPrompt),
1001
- );
1002
-
1003
- const registry = JSON.parse(
1004
- await readFile(join(tempDir, "session-registry.json"), "utf8"),
1005
- ) as Record<string, { running?: boolean }>;
1006
- expect(registry["chat-binding-recovery"]?.running).toBe(false);
1007
- });
1008
-
1009
- it("treats an authoritative final response that races timeout cleanup as completed", async () => {
1010
- vi.setSystemTime(0);
1011
- _setResponseStallTimeoutForTest(100);
1012
- _setResponseStallCheckIntervalForTest(10);
1013
- _setProcessAliveForTest(() => true);
1014
-
1015
- const platform = mockPlatform("feishu");
1016
- setSessionPlatform(platform);
1017
- bindChatToSession("sid-final-race", "chat-final-race");
1018
- recordLastActiveChat("sid-final-race", "chat-final-race");
1019
-
1020
- const receivedPrompts: string[] = [];
1021
- const adapter: ToolAdapter = {
1022
- displayName: "Any Agent",
1023
- sessionDescPrefix: "Agent Session:",
1024
- createSession: async () => ({ sessionId: "sid-final-race" }),
1025
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1026
- closeSession: async () => {},
1027
- prompt: async function* (
1028
- _sid: string,
1029
- text: string,
1030
- _cwd: string,
1031
- signal?: AbortSignal,
1032
- options?: ToolPromptOptions,
1033
- ) {
1034
- receivedPrompts.push(text);
1035
- options?.onProcessStart?.({ pid: 5151 });
1036
- yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1037
- await new Promise<void>((resolve) => {
1038
- if (signal?.aborted) {
1039
- resolve();
1040
- return;
1041
- }
1042
- signal?.addEventListener("abort", () => resolve(), { once: true });
1043
- });
1044
- yield {
1045
- type: "assistant",
1046
- blocks: [{ type: "text", text: "completed at the timeout boundary" }],
1047
- isFinalResponse: true,
1048
- };
1049
- },
1050
- };
1051
- _setAdapterForToolForTest("claude", adapter);
1052
-
1053
- const run = runAgentSession(
1054
- "sid-final-race",
1055
- "prompt",
1056
- platform,
1057
- "chat-final-race",
1058
- 0,
1059
- "claude",
1060
- );
1061
- await vi.waitFor(() => {
1062
- expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
1063
- });
1064
- const progress = activePrompts.get("sid-final-race")!.responseProgress!;
1065
- await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
1066
- await run;
1067
- await vi.advanceTimersByTimeAsync(1_000);
1068
-
1069
- expect(receivedPrompts).toHaveLength(1);
1070
- expect(mockStreamStates.get("sid-final-race")).toMatchObject({
1071
- status: "done",
1072
- finalReply: "completed at the timeout boundary",
1073
- });
1074
- expect(platform.sendText).not.toHaveBeenCalledWith(
1075
- "chat-final-race",
1076
- RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
1077
- );
1078
- });
1079
-
1080
- it("force-closes a stream that stays open after an authoritative final event without auto-recovery", async () => {
1081
- vi.setSystemTime(0);
1082
- _setResponseStallTimeoutForTest(100);
1083
- _setResponseStallCheckIntervalForTest(10);
1084
- _setFinalResponseCloseTimeoutForTest(1_000);
1085
- _setProcessAliveForTest(() => true);
1086
-
1087
- const platform = mockPlatform("feishu");
1088
- setSessionPlatform(platform);
1089
- bindChatToSession("sid-final-close", "chat-final-close");
1090
- recordLastActiveChat("sid-final-close", "chat-final-close");
1091
-
1092
- const closeSession = vi.fn();
1093
- const adapter: ToolAdapter = {
1094
- displayName: "Any Agent",
1095
- sessionDescPrefix: "Agent Session:",
1096
- createSession: async () => ({ sessionId: "sid-final-close" }),
1097
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1098
- closeSession: async () => {},
1099
- prompt: async function* (
1100
- _sid: string,
1101
- _text: string,
1102
- _cwd: string,
1103
- signal?: AbortSignal,
1104
- options?: ToolPromptOptions,
1105
- ) {
1106
- options?.onSessionCreated?.(closeSession);
1107
- options?.onProcessStart?.({ pid: 7171 });
1108
- yield {
1109
- type: "assistant",
1110
- blocks: [{ type: "text", text: "authoritative answer" }],
1111
- };
1112
- yield {
1113
- type: "assistant",
1114
- blocks: [],
1115
- isFinalResponse: true,
1116
- };
1117
- await new Promise<void>((resolve) => {
1118
- if (signal?.aborted) {
1119
- resolve();
1120
- return;
1121
- }
1122
- signal?.addEventListener("abort", () => resolve(), { once: true });
1123
- });
1124
- },
1125
- };
1126
- _setAdapterForToolForTest("claude", adapter);
1127
-
1128
- const run = runAgentSession(
1129
- "sid-final-close",
1130
- "prompt",
1131
- platform,
1132
- "chat-final-close",
1133
- 0,
1134
- "claude",
1135
- );
1136
-
1137
- await vi.waitFor(() => {
1138
- expect(activePrompts.get("sid-final-close")?.finalResponseObserved).toBe(true);
1139
- });
1140
- await vi.advanceTimersByTimeAsync(900);
1141
- expect(activePrompts.has("sid-final-close")).toBe(true);
1142
-
1143
- await vi.advanceTimersByTimeAsync(100);
1144
- await run;
1145
-
1146
- expect(closeSession).toHaveBeenCalledTimes(1);
1147
- expect(killProcessTreeMock).toHaveBeenCalledWith(7171);
1148
- expect(mockStreamStates.get("sid-final-close")).toMatchObject({
1149
- status: "done",
1150
- finalReply: "authoritative answer",
1151
- });
1152
- expect(platform.sendText).not.toHaveBeenCalledWith(
1153
- "chat-final-close",
1154
- expect.stringContaining(RESPONSE_STALL_RECOVERY_PROMPT),
1155
- );
1156
- });
1157
-
1158
- it("cancels the final-response close guard when the stream exits normally", async () => {
1159
- vi.setSystemTime(0);
1160
- _setFinalResponseCloseTimeoutForTest(1_000);
1161
- _setProcessAliveForTest(() => true);
1162
-
1163
- const platform = mockPlatform("feishu");
1164
- setSessionPlatform(platform);
1165
- bindChatToSession("sid-final-normal", "chat-final-normal");
1166
- recordLastActiveChat("sid-final-normal", "chat-final-normal");
1167
-
1168
- const closeSession = vi.fn();
1169
- const adapter: ToolAdapter = {
1170
- displayName: "Any Agent",
1171
- sessionDescPrefix: "Agent Session:",
1172
- createSession: async () => ({ sessionId: "sid-final-normal" }),
1173
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1174
- closeSession: async () => {},
1175
- prompt: async function* (
1176
- _sid: string,
1177
- _text: string,
1178
- _cwd: string,
1179
- _signal?: AbortSignal,
1180
- options?: ToolPromptOptions,
1181
- ) {
1182
- options?.onSessionCreated?.(closeSession);
1183
- options?.onProcessStart?.({ pid: 7272 });
1184
- yield {
1185
- type: "assistant",
1186
- blocks: [{ type: "text", text: "normal answer" }],
1187
- };
1188
- yield {
1189
- type: "assistant",
1190
- blocks: [],
1191
- isFinalResponse: true,
1192
- };
1193
- },
1194
- };
1195
- _setAdapterForToolForTest("claude", adapter);
1196
-
1197
- await runAgentSession(
1198
- "sid-final-normal",
1199
- "prompt",
1200
- platform,
1201
- "chat-final-normal",
1202
- 0,
1203
- "claude",
1204
- );
1205
- await vi.advanceTimersByTimeAsync(2_000);
1206
-
1207
- expect(closeSession).not.toHaveBeenCalled();
1208
- expect(killProcessTreeMock).not.toHaveBeenCalled();
1209
- expect(mockStreamStates.get("sid-final-normal")).toMatchObject({
1210
- status: "done",
1211
- finalReply: "normal answer",
1212
- });
1213
- });
1214
-
1215
- it("runs the reserved recovery prompt before an already queued user message", async () => {
1216
- vi.setSystemTime(0);
1217
- _setResponseStallTimeoutForTest(100);
1218
- _setResponseStallCheckIntervalForTest(10);
1219
- _setProcessAliveForTest(() => true);
1220
-
1221
- const platform = mockPlatform("feishu");
1222
- setSessionPlatform(platform);
1223
- bindChatToSession("sid-recovery-priority", "chat-recovery-priority");
1224
- recordLastActiveChat("sid-recovery-priority", "chat-recovery-priority");
1225
-
1226
- const receivedPrompts: string[] = [];
1227
- const adapter: ToolAdapter = {
1228
- displayName: "Any Agent",
1229
- sessionDescPrefix: "Agent Session:",
1230
- createSession: async () => ({ sessionId: "sid-recovery-priority" }),
1231
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1232
- closeSession: async () => {},
1233
- prompt: async function* (
1234
- _sid: string,
1235
- text: string,
1236
- _cwd: string,
1237
- signal?: AbortSignal,
1238
- options?: ToolPromptOptions,
1239
- ) {
1240
- receivedPrompts.push(text);
1241
- options?.onProcessStart?.({ pid: 5252 + receivedPrompts.length });
1242
- if (receivedPrompts.length === 1) {
1243
- yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1244
- await new Promise<void>((resolve) => {
1245
- if (signal?.aborted) {
1246
- resolve();
1247
- return;
1248
- }
1249
- signal?.addEventListener("abort", () => resolve(), { once: true });
1250
- });
1251
- return;
1252
- }
1253
- yield { type: "assistant", blocks: [{ type: "text", text: "recovery completed" }] };
1254
- },
1255
- };
1256
- _setAdapterForToolForTest("claude", adapter);
1257
-
1258
- enqueueMessage("sid-recovery-priority", {
1259
- text: "queued user prompt",
1260
- chatId: "chat-recovery-priority",
1261
- openId: "open-user",
1262
- msgTimestamp: 1,
1263
- chatType: "p2p",
1264
- });
1265
- const consumeQueued = vi.fn();
1266
- setQueueConsumer(consumeQueued);
1267
-
1268
- const firstRun = runAgentSession(
1269
- "sid-recovery-priority",
1270
- "first prompt",
1271
- platform,
1272
- "chat-recovery-priority",
1273
- 0,
1274
- "claude",
1275
- );
1276
-
1277
- await vi.waitFor(() => {
1278
- expect(activePrompts.get("sid-recovery-priority")?.responseProgress).toBeDefined();
1279
- });
1280
- const firstProgress = activePrompts.get("sid-recovery-priority")!.responseProgress!;
1281
- await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
1282
- await firstRun;
1283
-
1284
- expect(receivedPrompts).toHaveLength(1);
1285
- expect(consumeQueued).not.toHaveBeenCalled();
1286
- expect(isSessionRunning("sid-recovery-priority")).toBe(true);
1287
- expect(platform.sendText).toHaveBeenCalledWith(
1288
- "chat-recovery-priority",
1289
- expect.stringContaining(recoveryPrompt),
1290
- );
1291
-
1292
- await vi.advanceTimersByTimeAsync(200);
1293
- await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
1294
- expect(receivedPrompts[1]).toContain(recoveryPrompt);
1295
- expect(receivedPrompts[1]).not.toContain("queued user prompt");
1296
- expect(consumeQueued).not.toHaveBeenCalled();
1297
-
1298
- await vi.advanceTimersByTimeAsync(200);
1299
- await vi.waitFor(() => expect(consumeQueued).toHaveBeenCalledTimes(1));
1300
- expect(consumeQueued).toHaveBeenCalledWith(
1301
- platform,
1302
- expect.objectContaining({ text: "queued user prompt" }),
1303
- );
1304
- setQueueConsumer(() => {});
1305
- });
1306
-
1307
- it("does not auto-recover a second consecutive response stall", async () => {
1308
- vi.setSystemTime(0);
1309
- _setResponseStallTimeoutForTest(100);
1310
- _setResponseStallCheckIntervalForTest(10);
1311
- _setProcessAliveForTest(() => true);
1312
-
1313
- const platform = mockPlatform("feishu");
1314
- setSessionPlatform(platform);
1315
- bindChatToSession("sid-recovery-limit", "chat-recovery-limit");
1316
- recordLastActiveChat("sid-recovery-limit", "chat-recovery-limit");
1317
-
1318
- const receivedPrompts: string[] = [];
1319
- const adapter: ToolAdapter = {
1320
- displayName: "Any Agent",
1321
- sessionDescPrefix: "Agent Session:",
1322
- createSession: async () => ({ sessionId: "sid-recovery-limit" }),
1323
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1324
- closeSession: async () => {},
1325
- prompt: async function* (
1326
- _sid: string,
1327
- text: string,
1328
- _cwd: string,
1329
- signal?: AbortSignal,
1330
- options?: ToolPromptOptions,
1331
- ) {
1332
- receivedPrompts.push(text);
1333
- options?.onProcessStart?.({ pid: 6262 + receivedPrompts.length });
1334
- yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1335
- await new Promise<void>((resolve) => {
1336
- if (signal?.aborted) {
1337
- resolve();
1338
- return;
1339
- }
1340
- signal?.addEventListener("abort", () => resolve(), { once: true });
1341
- });
1342
- },
1343
- };
1344
- _setAdapterForToolForTest("claude", adapter);
1345
-
1346
- const firstRun = runAgentSession(
1347
- "sid-recovery-limit",
1348
- "first prompt",
1349
- platform,
1350
- "chat-recovery-limit",
1351
- 0,
1352
- "claude",
1353
- );
1354
- await vi.waitFor(() => {
1355
- expect(activePrompts.get("sid-recovery-limit")?.responseProgress).toBeDefined();
1356
- });
1357
- const firstProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
1358
- await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
1359
- await firstRun;
1360
-
1361
- await vi.advanceTimersByTimeAsync(200);
1362
- await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
1363
- const recoveryProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
1364
- await vi.advanceTimersByTimeAsync(recoveryProgress.unchangedSince + 101 - Date.now());
1365
- await vi.waitFor(() => expect(isSessionRunning("sid-recovery-limit")).toBe(false));
1366
-
1367
- await vi.advanceTimersByTimeAsync(1_000);
1368
- expect(receivedPrompts).toHaveLength(2);
1369
- expect(receivedPrompts[1]).toContain(recoveryPrompt);
1370
- expect(platform.sendText).toHaveBeenCalledWith(
1371
- "chat-recovery-limit",
1372
- "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。",
1373
- );
1374
- });
1375
-
1376
- it("lets /stop cancel a reserved recovery before it starts", async () => {
1377
- vi.setSystemTime(0);
1378
- _setResponseStallTimeoutForTest(100);
1379
- _setResponseStallCheckIntervalForTest(10);
1380
- _setProcessAliveForTest(() => true);
1381
-
1382
- const platform = mockPlatform("feishu");
1383
- setSessionPlatform(platform);
1384
- bindChatToSession("sid-recovery-stop", "chat-recovery-stop");
1385
- recordLastActiveChat("sid-recovery-stop", "chat-recovery-stop");
1386
-
1387
- const receivedPrompts: string[] = [];
1388
- const adapter: ToolAdapter = {
1389
- displayName: "Any Agent",
1390
- sessionDescPrefix: "Agent Session:",
1391
- createSession: async () => ({ sessionId: "sid-recovery-stop" }),
1392
- getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1393
- closeSession: async () => {},
1394
- prompt: async function* (
1395
- _sid: string,
1396
- text: string,
1397
- _cwd: string,
1398
- signal?: AbortSignal,
1399
- ) {
1400
- receivedPrompts.push(text);
1401
- yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1402
- await new Promise<void>((resolve) => {
1403
- if (signal?.aborted) {
1404
- resolve();
1405
- return;
1406
- }
1407
- signal?.addEventListener("abort", () => resolve(), { once: true });
1408
- });
1409
- },
1410
- };
1411
- _setAdapterForToolForTest("claude", adapter);
1412
-
1413
- const firstRun = runAgentSession(
1414
- "sid-recovery-stop",
1415
- "first prompt",
1416
- platform,
1417
- "chat-recovery-stop",
1418
- 0,
1419
- "claude",
1420
- );
1421
- await vi.waitFor(() => {
1422
- expect(activePrompts.get("sid-recovery-stop")?.responseProgress).toBeDefined();
1423
- });
1424
- const progress = activePrompts.get("sid-recovery-stop")!.responseProgress!;
1425
- await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
1426
- await firstRun;
1427
-
1428
- expect(isSessionRunning("sid-recovery-stop")).toBe(true);
1429
- expect(stopSession("sid-recovery-stop")).toBe(true);
1430
- await vi.advanceTimersByTimeAsync(500);
1431
-
1432
- expect(receivedPrompts).toHaveLength(1);
1433
- expect(isSessionRunning("sid-recovery-stop")).toBe(false);
1434
- });
1435
- });
1436
-
1437
- describe("unified display loop WeChat delta", () => {
1438
- beforeEach(() => {
750
+ describe("runAgentSession response stall watchdog", () => {
751
+ let tempDir = "";
752
+ const recoveryPrompt = RESPONSE_STALL_RECOVERY_PROMPT;
753
+
754
+ beforeEach(async () => {
1439
755
  vi.useFakeTimers();
1440
756
  resetState();
1441
757
  resetBindingState();
1442
758
  mockStreamStates.clear();
759
+ killProcessTreeMock.mockClear();
760
+ tempDir = await mkdtemp(join(tmpdir(), "chatccc-response-stall-"));
761
+ _setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
762
+ _setSessionToolsFileForTest(join(tempDir, "session-tools.json"));
1443
763
  });
1444
764
 
1445
- afterEach(() => {
1446
- stopUnifiedDisplayLoop();
765
+ afterEach(async () => {
766
+ _resetSessionRegistryFileForTest();
767
+ _resetSessionToolsFileForTest();
768
+ _clearAdapterCacheForTest();
769
+ _resetProcessAliveForTest();
770
+ _resetResponseStallTimeoutForTest();
771
+ _resetResponseStallCheckIntervalForTest();
772
+ _resetFinalResponseCloseTimeoutForTest();
1447
773
  resetBindingState();
1448
774
  vi.useRealTimers();
775
+ if (tempDir) await rm(tempDir, { recursive: true, force: true });
1449
776
  });
1450
777
 
1451
- it("sends only new accumulated content when tool output arrives before an already-sent final reply", async () => {
1452
- const platform = mockPlatform("wechat");
1453
- platform.kind = "wechat";
778
+ it("auto-ends every Agent after three minutes of unchanged reply characters, including zero", async () => {
779
+ vi.setSystemTime(0);
780
+ _setResponseStallTimeoutForTest(180_000);
781
+ _setResponseStallCheckIntervalForTest(1_000);
782
+ _setProcessAliveForTest(() => true);
783
+
784
+ const platform = mockPlatform("feishu");
1454
785
  setSessionPlatform(platform);
786
+ bindChatToSession("sid-response-stall", "chat-response-stall");
787
+ recordLastActiveChat("sid-response-stall", "chat-response-stall");
1455
788
 
1456
- bindChatToSession("sid-wechat", "chat-wechat");
1457
- recordLastActiveChat("sid-wechat", "chat-wechat");
1458
- sessionInfoMap.set("chat-wechat", {
1459
- sessionId: "sid-wechat",
1460
- turnCount: 1,
1461
- lastContextTokens: 0,
1462
- startTime: 0,
1463
- tool: "claude",
1464
- });
789
+ const closeSession = vi.fn();
790
+ const adapter: ToolAdapter = {
791
+ displayName: "Any Agent",
792
+ sessionDescPrefix: "Agent Session:",
793
+ createSession: async () => ({ sessionId: "sid-response-stall" }),
794
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
795
+ closeSession: async () => {},
796
+ prompt: async function* (
797
+ _sid: string,
798
+ _text: string,
799
+ _cwd: string,
800
+ signal?: AbortSignal,
801
+ options?: ToolPromptOptions,
802
+ ) {
803
+ options?.onSessionCreated?.(closeSession);
804
+ options?.onProcessStart?.({ pid: 4242 });
805
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
806
+ await new Promise<void>((resolve) => {
807
+ if (signal?.aborted) {
808
+ resolve();
809
+ return;
810
+ }
811
+ signal?.addEventListener("abort", () => resolve(), { once: true });
812
+ });
813
+ },
814
+ };
815
+ _setAdapterForToolForTest("claude", adapter);
1465
816
 
1466
- // 模拟 runAgentSession 创建的 WeChat display 条目
1467
- displayCards.set("chat-wechat", {
1468
- cardId: "",
1469
- sequence: 0,
1470
- cardBusy: false,
1471
- cardCreatedAt: Date.now(),
1472
- lastSentContent: "",
1473
- streamErrorNotified: false,
1474
- sessionId: "sid-wechat",
1475
- turnCount: 1,
1476
- dotCount: 0,
1477
- });
817
+ const runPromise = runAgentSession(
818
+ "sid-response-stall",
819
+ "prompt",
820
+ platform,
821
+ "chat-response-stall",
822
+ Date.now(),
823
+ "claude",
824
+ );
1478
825
 
1479
- mockStreamStates.set("sid-wechat", {
1480
- accumulatedContent: "",
1481
- finalReply: "partial reply",
1482
- status: "running",
826
+ await vi.waitFor(() => {
827
+ expect(activePrompts.get("sid-response-stall")?.responseProgress).toEqual({
828
+ totalChars: 0,
829
+ unchangedSince: expect.any(Number),
830
+ });
1483
831
  });
1484
- startUnifiedDisplayLoop();
1485
- await vi.advanceTimersByTimeAsync(3000);
1486
832
 
1487
- mockStreamStates.set("sid-wechat", {
1488
- accumulatedContent: "tool output\n",
1489
- finalReply: "partial reply",
1490
- status: "running",
1491
- });
1492
- await vi.advanceTimersByTimeAsync(3000);
833
+ const progress = activePrompts.get("sid-response-stall")!.responseProgress!;
834
+ const remainingBeforeBoundary = progress.unchangedSince + 180_000 - Date.now() - 1;
835
+ await vi.advanceTimersByTimeAsync(remainingBeforeBoundary);
836
+ expect(activePrompts.has("sid-response-stall")).toBe(true);
1493
837
 
1494
- expect(platform.sendText).toHaveBeenNthCalledWith(
1495
- 1,
1496
- "chat-wechat",
1497
- "partial reply",
1498
- );
1499
- expect(platform.sendText).toHaveBeenNthCalledWith(
1500
- 2,
1501
- "chat-wechat",
1502
- "tool output",
1503
- );
1504
- });
1505
-
1506
- });
1507
-
1508
- describe("unified display loop activity status", () => {
1509
- beforeEach(() => {
1510
- vi.useFakeTimers();
1511
- vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
1512
- resetState();
1513
- resetBindingState();
1514
- mockStreamStates.clear();
1515
- });
1516
-
1517
- afterEach(() => {
1518
- stopUnifiedDisplayLoop();
1519
- resetBindingState();
1520
- vi.useRealTimers();
1521
- });
1522
-
1523
- it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
1524
- const platform = mockPlatform("feishu");
1525
- setSessionPlatform(platform);
1526
-
1527
- bindChatToSession("sid-activity", "chat-activity");
1528
- recordLastActiveChat("sid-activity", "chat-activity");
1529
- sessionInfoMap.set("chat-activity", {
1530
- sessionId: "sid-activity",
1531
- turnCount: 1,
1532
- lastContextTokens: 0,
1533
- startTime: Date.now() - 12_000,
1534
- tool: "codex",
1535
- });
1536
- displayCards.set("chat-activity", {
1537
- cardId: "card-activity",
1538
- sequence: 1,
1539
- cardBusy: false,
1540
- cardCreatedAt: Date.now(),
1541
- lastSentContent: "",
1542
- streamErrorNotified: false,
1543
- sessionId: "sid-activity",
1544
- turnCount: 1,
1545
- dotCount: 0,
1546
- });
1547
- mockStreamStates.set("sid-activity", {
1548
- accumulatedContent: "正在检查日志",
1549
- finalReply: "",
1550
- activity: {
1551
- kind: "tool",
1552
- startedAt: Date.now() - 12_000,
1553
- toolName: "Shell",
1554
- toolCount: 1,
1555
- },
1556
- status: "running",
1557
- turnCount: 1,
1558
- });
1559
-
1560
- startUnifiedDisplayLoop();
1561
- await vi.advanceTimersByTimeAsync(3_000);
1562
-
1563
- const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
1564
- expect(payload).toBeTypeOf("string");
1565
- const card = JSON.parse(payload as string) as {
1566
- header: { title: { content: string } };
1567
- body: { elements: Array<{ tag: string; content?: string }> };
1568
- };
1569
- expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
1570
- expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
1571
- });
1572
- });
1573
-
1574
- describe("unified display loop terminal card update", () => {
1575
- beforeEach(() => {
1576
- vi.useFakeTimers();
1577
- resetState();
1578
- resetBindingState();
1579
- mockStreamStates.clear();
838
+ await vi.advanceTimersByTimeAsync(1_001);
839
+ await runPromise;
840
+
841
+ expect(closeSession).toHaveBeenCalledTimes(1);
842
+ expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
843
+ expect(activePrompts.has("sid-response-stall")).toBe(false);
844
+ expect(mockStreamStates.get("sid-response-stall")).toMatchObject({
845
+ status: "auto_ended",
846
+ finalReply: "",
847
+ autoEndedAt: expect.any(Number),
848
+ });
1580
849
  });
1581
850
 
1582
- afterEach(() => {
1583
- stopUnifiedDisplayLoop();
1584
- resetBindingState();
1585
- vi.useRealTimers();
1586
- });
1587
-
1588
- it("shows a distinct auto-ended state and sends a warning even with an empty reply", async () => {
1589
- const platform = mockPlatform("feishu");
1590
- setSessionPlatform(platform);
1591
-
1592
- bindChatToSession("sid-auto-ended", "chat-auto-ended");
1593
- recordLastActiveChat("sid-auto-ended", "chat-auto-ended");
1594
- sessionInfoMap.set("chat-auto-ended", {
1595
- sessionId: "sid-auto-ended",
1596
- turnCount: 1,
1597
- lastContextTokens: 0,
1598
- startTime: 0,
1599
- tool: "cursor",
1600
- });
1601
- displayCards.set("chat-auto-ended", {
1602
- cardId: "card-auto-ended",
1603
- sequence: 1,
1604
- cardBusy: false,
1605
- cardCreatedAt: Date.now(),
1606
- lastSentContent: "",
1607
- streamErrorNotified: false,
1608
- sessionId: "sid-auto-ended",
1609
- turnCount: 1,
1610
- dotCount: 0,
1611
- });
1612
- mockStreamStates.set("sid-auto-ended", {
1613
- accumulatedContent: "",
1614
- finalReply: "",
1615
- status: "auto_ended",
1616
- turnCount: 1,
1617
- autoEndedAt: Date.now(),
1618
- });
1619
-
1620
- startUnifiedDisplayLoop();
1621
- await vi.advanceTimersByTimeAsync(3_000);
1622
-
1623
- const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
1624
- const card = JSON.parse(payload as string) as {
1625
- header: { title: { content: string }; template?: string };
1626
- };
1627
- expect(card.header.title.content).toBe("已自动结束 · 3分钟无新内容");
1628
- expect(card.header.template).toBe("orange");
1629
- expect(platform.sendText).toHaveBeenCalledWith(
1630
- "chat-auto-ended",
1631
- "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。本轮没有可发送的回复内容。",
1632
- );
1633
- expect(displayCards.has("chat-auto-ended")).toBe(false);
1634
- });
1635
-
1636
- it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
851
+ it("auto-ends an Agent that stays in starting state without emitting any event", async () => {
852
+ vi.setSystemTime(0);
853
+ _setResponseStallTimeoutForTest(180_000);
854
+ _setResponseStallCheckIntervalForTest(1_000);
855
+ _setProcessAliveForTest(() => true);
856
+
1637
857
  const platform = mockPlatform("feishu");
1638
- platform.cardUpdate = vi.fn(async () => {
1639
- throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
1640
- });
1641
858
  setSessionPlatform(platform);
859
+ bindChatToSession("sid-starting-stall", "chat-starting-stall");
860
+ recordLastActiveChat("sid-starting-stall", "chat-starting-stall");
1642
861
 
1643
- bindChatToSession("sid-terminal", "chat-terminal");
1644
- recordLastActiveChat("sid-terminal", "chat-terminal");
1645
- sessionInfoMap.set("chat-terminal", {
1646
- sessionId: "sid-terminal",
1647
- turnCount: 1,
1648
- lastContextTokens: 0,
1649
- startTime: 0,
1650
- tool: "claude",
1651
- });
1652
- activePrompts.set("sid-terminal", {
1653
- controller: new AbortController(),
1654
- stopped: false,
1655
- startTime: Date.now(),
1656
- });
1657
- displayCards.set("chat-terminal", {
1658
- cardId: "card-terminal",
1659
- sequence: 109,
1660
- cardBusy: false,
1661
- cardCreatedAt: Date.now(),
1662
- lastSentContent: "",
1663
- streamErrorNotified: false,
1664
- sessionId: "sid-terminal",
1665
- turnCount: 1,
1666
- dotCount: 0,
1667
- });
1668
- mockStreamStates.set("sid-terminal", {
1669
- accumulatedContent: "partial tool output",
1670
- finalReply: "",
1671
- status: "stopped",
1672
- turnCount: 1,
862
+ const closeSession = vi.fn();
863
+ const adapter: ToolAdapter = {
864
+ displayName: "Silent Agent",
865
+ sessionDescPrefix: "Agent Session:",
866
+ createSession: async () => ({ sessionId: "sid-starting-stall" }),
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
+ options?.onSessionCreated?.(closeSession);
877
+ options?.onProcessStart?.({ pid: 4343 });
878
+ await new Promise<void>((resolve) => {
879
+ if (signal?.aborted) {
880
+ resolve();
881
+ return;
882
+ }
883
+ signal?.addEventListener("abort", () => resolve(), { once: true });
884
+ });
885
+ if (false) yield { type: "assistant", blocks: [] };
886
+ },
887
+ };
888
+ _setAdapterForToolForTest("codex", adapter);
889
+
890
+ const runPromise = runAgentSession(
891
+ "sid-starting-stall",
892
+ "prompt",
893
+ platform,
894
+ "chat-starting-stall",
895
+ Date.now(),
896
+ "codex",
897
+ );
898
+
899
+ await vi.waitFor(() => {
900
+ expect(activePrompts.get("sid-starting-stall")).toMatchObject({
901
+ processPid: 4343,
902
+ responseProgress: {
903
+ totalChars: 0,
904
+ unchangedSince: expect.any(Number),
905
+ },
906
+ });
907
+ expect(closeSession).toHaveBeenCalledTimes(0);
1673
908
  });
1674
909
 
1675
- startUnifiedDisplayLoop();
1676
- await vi.advanceTimersByTimeAsync(3000);
1677
- await vi.advanceTimersByTimeAsync(3000);
910
+ await vi.advanceTimersByTimeAsync(181_001);
911
+ const stateAfterDeadline = mockStreamStates.get("sid-starting-stall");
912
+ const closeCountAfterDeadline = closeSession.mock.calls.length;
913
+ const treeKillCountAfterDeadline = killProcessTreeMock.mock.calls
914
+ .filter(([pid]) => pid === 4343)
915
+ .length;
916
+
917
+ // 旧实现不会结束真正的零事件启动;先清理测试会话,避免失败用例悬挂。
918
+ if (stateAfterDeadline?.status !== "auto_ended") {
919
+ stopSession("sid-starting-stall");
920
+ await vi.advanceTimersByTimeAsync(1_000);
921
+ }
922
+ await runPromise;
1678
923
 
1679
- expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
1680
- expect(platform.cardUpdate).toHaveBeenCalledWith(
1681
- "card-terminal",
1682
- expect.any(String),
1683
- 110,
1684
- );
1685
- expect(displayCards.get("chat-terminal")?.sequence).toBe(110);
924
+ expect(stateAfterDeadline).toMatchObject({
925
+ status: "auto_ended",
926
+ finalReply: "",
927
+ autoEndedAt: expect.any(Number),
928
+ });
929
+ expect(closeCountAfterDeadline).toBe(1);
930
+ expect(treeKillCountAfterDeadline).toBeGreaterThanOrEqual(1);
1686
931
  });
1687
932
 
1688
- it("keeps terminal display and retries final text when sending fails", async () => {
933
+ it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
934
+ vi.setSystemTime(0);
935
+ _setResponseStallTimeoutForTest(100);
936
+ _setResponseStallCheckIntervalForTest(10);
937
+ _setProcessAliveForTest(() => true);
938
+
939
+ const platform = mockPlatform("feishu");
940
+ setSessionPlatform(platform);
941
+
942
+ const receivedPrompts: string[] = [];
943
+ const adapter: ToolAdapter = {
944
+ displayName: "Any Agent",
945
+ sessionDescPrefix: "Agent Session:",
946
+ createSession: async () => ({ sessionId: "sid-binding-recovery" }),
947
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
948
+ closeSession: async () => {},
949
+ prompt: async function* (
950
+ _sid: string,
951
+ text: string,
952
+ _cwd: string,
953
+ signal?: AbortSignal,
954
+ options?: ToolPromptOptions,
955
+ ) {
956
+ receivedPrompts.push(text);
957
+ options?.onProcessStart?.({ pid: 5000 + receivedPrompts.length });
958
+ if (receivedPrompts.length === 1) {
959
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
960
+ await new Promise<void>((resolve) => {
961
+ if (signal?.aborted) {
962
+ resolve();
963
+ return;
964
+ }
965
+ signal?.addEventListener("abort", () => resolve(), { once: true });
966
+ });
967
+ return;
968
+ }
969
+ yield {
970
+ type: "assistant",
971
+ blocks: [{ type: "text", text: "recovery completed" }],
972
+ isFinalResponse: true,
973
+ };
974
+ },
975
+ };
976
+ _setAdapterForToolForTest("claude", adapter);
977
+
978
+ const firstRun = runAgentSession(
979
+ "sid-binding-recovery",
980
+ "first prompt",
981
+ platform,
982
+ "chat-binding-recovery",
983
+ 0,
984
+ "claude",
985
+ );
986
+
987
+ await vi.waitFor(() => {
988
+ expect(activePrompts.get("sid-binding-recovery")?.responseProgress).toBeDefined();
989
+ });
990
+ const progress = activePrompts.get("sid-binding-recovery")!.responseProgress!;
991
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
992
+ await firstRun;
993
+ await vi.advanceTimersByTimeAsync(200);
994
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
995
+ await vi.waitFor(() => expect(isSessionRunning("sid-binding-recovery")).toBe(false));
996
+
997
+ expect(getChatsForSession("sid-binding-recovery")).toContain("chat-binding-recovery");
998
+ expect(platform.cardCreate).toHaveBeenCalledTimes(2);
999
+ expect(platform.cardSend).toHaveBeenCalledTimes(2);
1000
+ expect(platform.sendText).toHaveBeenCalledWith(
1001
+ "chat-binding-recovery",
1002
+ expect.stringContaining(recoveryPrompt),
1003
+ );
1004
+
1005
+ const registry = JSON.parse(
1006
+ await readFile(join(tempDir, "session-registry.json"), "utf8"),
1007
+ ) as Record<string, { running?: boolean }>;
1008
+ expect(registry["chat-binding-recovery"]?.running).toBe(false);
1009
+ });
1010
+
1011
+ it("treats an authoritative final response that races timeout cleanup as completed", async () => {
1012
+ vi.setSystemTime(0);
1013
+ _setResponseStallTimeoutForTest(100);
1014
+ _setResponseStallCheckIntervalForTest(10);
1015
+ _setProcessAliveForTest(() => true);
1016
+
1017
+ const platform = mockPlatform("feishu");
1018
+ setSessionPlatform(platform);
1019
+ bindChatToSession("sid-final-race", "chat-final-race");
1020
+ recordLastActiveChat("sid-final-race", "chat-final-race");
1021
+
1022
+ const receivedPrompts: string[] = [];
1023
+ const adapter: ToolAdapter = {
1024
+ displayName: "Any Agent",
1025
+ sessionDescPrefix: "Agent Session:",
1026
+ createSession: async () => ({ sessionId: "sid-final-race" }),
1027
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1028
+ closeSession: async () => {},
1029
+ prompt: async function* (
1030
+ _sid: string,
1031
+ text: string,
1032
+ _cwd: string,
1033
+ signal?: AbortSignal,
1034
+ options?: ToolPromptOptions,
1035
+ ) {
1036
+ receivedPrompts.push(text);
1037
+ options?.onProcessStart?.({ pid: 5151 });
1038
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1039
+ await new Promise<void>((resolve) => {
1040
+ if (signal?.aborted) {
1041
+ resolve();
1042
+ return;
1043
+ }
1044
+ signal?.addEventListener("abort", () => resolve(), { once: true });
1045
+ });
1046
+ yield {
1047
+ type: "assistant",
1048
+ blocks: [{ type: "text", text: "completed at the timeout boundary" }],
1049
+ isFinalResponse: true,
1050
+ };
1051
+ },
1052
+ };
1053
+ _setAdapterForToolForTest("claude", adapter);
1054
+
1055
+ const run = runAgentSession(
1056
+ "sid-final-race",
1057
+ "prompt",
1058
+ platform,
1059
+ "chat-final-race",
1060
+ 0,
1061
+ "claude",
1062
+ );
1063
+ await vi.waitFor(() => {
1064
+ expect(activePrompts.get("sid-final-race")?.responseProgress).toBeDefined();
1065
+ });
1066
+ const progress = activePrompts.get("sid-final-race")!.responseProgress!;
1067
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
1068
+ await run;
1069
+ await vi.advanceTimersByTimeAsync(1_000);
1070
+
1071
+ expect(receivedPrompts).toHaveLength(1);
1072
+ expect(mockStreamStates.get("sid-final-race")).toMatchObject({
1073
+ status: "done",
1074
+ finalReply: "completed at the timeout boundary",
1075
+ });
1076
+ expect(platform.sendText).not.toHaveBeenCalledWith(
1077
+ "chat-final-race",
1078
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
1079
+ );
1080
+ });
1081
+
1082
+ it("force-closes a stream that stays open after an authoritative final event without auto-recovery", async () => {
1083
+ vi.setSystemTime(0);
1084
+ _setResponseStallTimeoutForTest(100);
1085
+ _setResponseStallCheckIntervalForTest(10);
1086
+ _setFinalResponseCloseTimeoutForTest(1_000);
1087
+ _setProcessAliveForTest(() => true);
1088
+
1089
+ const platform = mockPlatform("feishu");
1090
+ setSessionPlatform(platform);
1091
+ bindChatToSession("sid-final-close", "chat-final-close");
1092
+ recordLastActiveChat("sid-final-close", "chat-final-close");
1093
+
1094
+ const closeSession = vi.fn();
1095
+ const adapter: ToolAdapter = {
1096
+ displayName: "Any Agent",
1097
+ sessionDescPrefix: "Agent Session:",
1098
+ createSession: async () => ({ sessionId: "sid-final-close" }),
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
+ options?: ToolPromptOptions,
1107
+ ) {
1108
+ options?.onSessionCreated?.(closeSession);
1109
+ options?.onProcessStart?.({ pid: 7171 });
1110
+ yield {
1111
+ type: "assistant",
1112
+ blocks: [{ type: "text", text: "authoritative answer" }],
1113
+ };
1114
+ yield {
1115
+ type: "assistant",
1116
+ blocks: [],
1117
+ isFinalResponse: true,
1118
+ };
1119
+ await new Promise<void>((resolve) => {
1120
+ if (signal?.aborted) {
1121
+ resolve();
1122
+ return;
1123
+ }
1124
+ signal?.addEventListener("abort", () => resolve(), { once: true });
1125
+ });
1126
+ },
1127
+ };
1128
+ _setAdapterForToolForTest("claude", adapter);
1129
+
1130
+ const run = runAgentSession(
1131
+ "sid-final-close",
1132
+ "prompt",
1133
+ platform,
1134
+ "chat-final-close",
1135
+ 0,
1136
+ "claude",
1137
+ );
1138
+
1139
+ await vi.waitFor(() => {
1140
+ expect(activePrompts.get("sid-final-close")?.finalResponseObserved).toBe(true);
1141
+ });
1142
+ await vi.advanceTimersByTimeAsync(900);
1143
+ expect(activePrompts.has("sid-final-close")).toBe(true);
1144
+
1145
+ await vi.advanceTimersByTimeAsync(100);
1146
+ await run;
1147
+
1148
+ expect(closeSession).toHaveBeenCalledTimes(1);
1149
+ expect(killProcessTreeMock).toHaveBeenCalledWith(7171);
1150
+ expect(mockStreamStates.get("sid-final-close")).toMatchObject({
1151
+ status: "done",
1152
+ finalReply: "authoritative answer",
1153
+ });
1154
+ expect(platform.sendText).not.toHaveBeenCalledWith(
1155
+ "chat-final-close",
1156
+ expect.stringContaining(RESPONSE_STALL_RECOVERY_PROMPT),
1157
+ );
1158
+ });
1159
+
1160
+ it("cancels the final-response close guard when the stream exits normally", async () => {
1161
+ vi.setSystemTime(0);
1162
+ _setFinalResponseCloseTimeoutForTest(1_000);
1163
+ _setProcessAliveForTest(() => true);
1164
+
1165
+ const platform = mockPlatform("feishu");
1166
+ setSessionPlatform(platform);
1167
+ bindChatToSession("sid-final-normal", "chat-final-normal");
1168
+ recordLastActiveChat("sid-final-normal", "chat-final-normal");
1169
+
1170
+ const closeSession = vi.fn();
1171
+ const adapter: ToolAdapter = {
1172
+ displayName: "Any Agent",
1173
+ sessionDescPrefix: "Agent Session:",
1174
+ createSession: async () => ({ sessionId: "sid-final-normal" }),
1175
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1176
+ closeSession: async () => {},
1177
+ prompt: async function* (
1178
+ _sid: string,
1179
+ _text: string,
1180
+ _cwd: string,
1181
+ _signal?: AbortSignal,
1182
+ options?: ToolPromptOptions,
1183
+ ) {
1184
+ options?.onSessionCreated?.(closeSession);
1185
+ options?.onProcessStart?.({ pid: 7272 });
1186
+ yield {
1187
+ type: "assistant",
1188
+ blocks: [{ type: "text", text: "normal answer" }],
1189
+ };
1190
+ yield {
1191
+ type: "assistant",
1192
+ blocks: [],
1193
+ isFinalResponse: true,
1194
+ };
1195
+ },
1196
+ };
1197
+ _setAdapterForToolForTest("claude", adapter);
1198
+
1199
+ await runAgentSession(
1200
+ "sid-final-normal",
1201
+ "prompt",
1202
+ platform,
1203
+ "chat-final-normal",
1204
+ 0,
1205
+ "claude",
1206
+ );
1207
+ await vi.advanceTimersByTimeAsync(2_000);
1208
+
1209
+ expect(closeSession).not.toHaveBeenCalled();
1210
+ expect(killProcessTreeMock).not.toHaveBeenCalled();
1211
+ expect(mockStreamStates.get("sid-final-normal")).toMatchObject({
1212
+ status: "done",
1213
+ finalReply: "normal answer",
1214
+ });
1215
+ });
1216
+
1217
+ it("runs the reserved recovery prompt before an already queued user message", async () => {
1218
+ vi.setSystemTime(0);
1219
+ _setResponseStallTimeoutForTest(100);
1220
+ _setResponseStallCheckIntervalForTest(10);
1221
+ _setProcessAliveForTest(() => true);
1222
+
1223
+ const platform = mockPlatform("feishu");
1224
+ setSessionPlatform(platform);
1225
+ bindChatToSession("sid-recovery-priority", "chat-recovery-priority");
1226
+ recordLastActiveChat("sid-recovery-priority", "chat-recovery-priority");
1227
+
1228
+ const receivedPrompts: string[] = [];
1229
+ const adapter: ToolAdapter = {
1230
+ displayName: "Any Agent",
1231
+ sessionDescPrefix: "Agent Session:",
1232
+ createSession: async () => ({ sessionId: "sid-recovery-priority" }),
1233
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1234
+ closeSession: async () => {},
1235
+ prompt: async function* (
1236
+ _sid: string,
1237
+ text: string,
1238
+ _cwd: string,
1239
+ signal?: AbortSignal,
1240
+ options?: ToolPromptOptions,
1241
+ ) {
1242
+ receivedPrompts.push(text);
1243
+ options?.onProcessStart?.({ pid: 5252 + receivedPrompts.length });
1244
+ if (receivedPrompts.length === 1) {
1245
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1246
+ await new Promise<void>((resolve) => {
1247
+ if (signal?.aborted) {
1248
+ resolve();
1249
+ return;
1250
+ }
1251
+ signal?.addEventListener("abort", () => resolve(), { once: true });
1252
+ });
1253
+ return;
1254
+ }
1255
+ yield { type: "assistant", blocks: [{ type: "text", text: "recovery completed" }] };
1256
+ },
1257
+ };
1258
+ _setAdapterForToolForTest("claude", adapter);
1259
+
1260
+ enqueueMessage("sid-recovery-priority", {
1261
+ text: "queued user prompt",
1262
+ chatId: "chat-recovery-priority",
1263
+ openId: "open-user",
1264
+ msgTimestamp: 1,
1265
+ chatType: "p2p",
1266
+ });
1267
+ const consumeQueued = vi.fn();
1268
+ setQueueConsumer(consumeQueued);
1269
+
1270
+ const firstRun = runAgentSession(
1271
+ "sid-recovery-priority",
1272
+ "first prompt",
1273
+ platform,
1274
+ "chat-recovery-priority",
1275
+ 0,
1276
+ "claude",
1277
+ );
1278
+
1279
+ await vi.waitFor(() => {
1280
+ expect(activePrompts.get("sid-recovery-priority")?.responseProgress).toBeDefined();
1281
+ });
1282
+ const firstProgress = activePrompts.get("sid-recovery-priority")!.responseProgress!;
1283
+ await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
1284
+ await firstRun;
1285
+
1286
+ expect(receivedPrompts).toHaveLength(1);
1287
+ expect(consumeQueued).not.toHaveBeenCalled();
1288
+ expect(isSessionRunning("sid-recovery-priority")).toBe(true);
1289
+ expect(platform.sendText).toHaveBeenCalledWith(
1290
+ "chat-recovery-priority",
1291
+ expect.stringContaining(recoveryPrompt),
1292
+ );
1293
+
1294
+ await vi.advanceTimersByTimeAsync(200);
1295
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
1296
+ expect(receivedPrompts[1]).toContain(recoveryPrompt);
1297
+ expect(receivedPrompts[1]).not.toContain("queued user prompt");
1298
+ expect(consumeQueued).not.toHaveBeenCalled();
1299
+
1300
+ await vi.advanceTimersByTimeAsync(200);
1301
+ await vi.waitFor(() => expect(consumeQueued).toHaveBeenCalledTimes(1));
1302
+ expect(consumeQueued).toHaveBeenCalledWith(
1303
+ platform,
1304
+ expect.objectContaining({ text: "queued user prompt" }),
1305
+ );
1306
+ setQueueConsumer(() => {});
1307
+ });
1308
+
1309
+ it("does not auto-recover a second consecutive response stall", async () => {
1310
+ vi.setSystemTime(0);
1311
+ _setResponseStallTimeoutForTest(100);
1312
+ _setResponseStallCheckIntervalForTest(10);
1313
+ _setProcessAliveForTest(() => true);
1314
+
1315
+ const platform = mockPlatform("feishu");
1316
+ setSessionPlatform(platform);
1317
+ bindChatToSession("sid-recovery-limit", "chat-recovery-limit");
1318
+ recordLastActiveChat("sid-recovery-limit", "chat-recovery-limit");
1319
+
1320
+ const receivedPrompts: string[] = [];
1321
+ const adapter: ToolAdapter = {
1322
+ displayName: "Any Agent",
1323
+ sessionDescPrefix: "Agent Session:",
1324
+ createSession: async () => ({ sessionId: "sid-recovery-limit" }),
1325
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1326
+ closeSession: async () => {},
1327
+ prompt: async function* (
1328
+ _sid: string,
1329
+ text: string,
1330
+ _cwd: string,
1331
+ signal?: AbortSignal,
1332
+ options?: ToolPromptOptions,
1333
+ ) {
1334
+ receivedPrompts.push(text);
1335
+ options?.onProcessStart?.({ pid: 6262 + receivedPrompts.length });
1336
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1337
+ await new Promise<void>((resolve) => {
1338
+ if (signal?.aborted) {
1339
+ resolve();
1340
+ return;
1341
+ }
1342
+ signal?.addEventListener("abort", () => resolve(), { once: true });
1343
+ });
1344
+ },
1345
+ };
1346
+ _setAdapterForToolForTest("claude", adapter);
1347
+
1348
+ const firstRun = runAgentSession(
1349
+ "sid-recovery-limit",
1350
+ "first prompt",
1351
+ platform,
1352
+ "chat-recovery-limit",
1353
+ 0,
1354
+ "claude",
1355
+ );
1356
+ await vi.waitFor(() => {
1357
+ expect(activePrompts.get("sid-recovery-limit")?.responseProgress).toBeDefined();
1358
+ });
1359
+ const firstProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
1360
+ await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
1361
+ await firstRun;
1362
+
1363
+ await vi.advanceTimersByTimeAsync(200);
1364
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
1365
+ const recoveryProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
1366
+ await vi.advanceTimersByTimeAsync(recoveryProgress.unchangedSince + 101 - Date.now());
1367
+ await vi.waitFor(() => expect(isSessionRunning("sid-recovery-limit")).toBe(false));
1368
+
1369
+ await vi.advanceTimersByTimeAsync(1_000);
1370
+ expect(receivedPrompts).toHaveLength(2);
1371
+ expect(receivedPrompts[1]).toContain(recoveryPrompt);
1372
+ expect(platform.sendText).toHaveBeenCalledWith(
1373
+ "chat-recovery-limit",
1374
+ "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。",
1375
+ );
1376
+ });
1377
+
1378
+ it("lets /stop cancel a reserved recovery before it starts", async () => {
1379
+ vi.setSystemTime(0);
1380
+ _setResponseStallTimeoutForTest(100);
1381
+ _setResponseStallCheckIntervalForTest(10);
1382
+ _setProcessAliveForTest(() => true);
1383
+
1384
+ const platform = mockPlatform("feishu");
1385
+ setSessionPlatform(platform);
1386
+ bindChatToSession("sid-recovery-stop", "chat-recovery-stop");
1387
+ recordLastActiveChat("sid-recovery-stop", "chat-recovery-stop");
1388
+
1389
+ const receivedPrompts: string[] = [];
1390
+ const adapter: ToolAdapter = {
1391
+ displayName: "Any Agent",
1392
+ sessionDescPrefix: "Agent Session:",
1393
+ createSession: async () => ({ sessionId: "sid-recovery-stop" }),
1394
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1395
+ closeSession: async () => {},
1396
+ prompt: async function* (
1397
+ _sid: string,
1398
+ text: string,
1399
+ _cwd: string,
1400
+ signal?: AbortSignal,
1401
+ ) {
1402
+ receivedPrompts.push(text);
1403
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
1404
+ await new Promise<void>((resolve) => {
1405
+ if (signal?.aborted) {
1406
+ resolve();
1407
+ return;
1408
+ }
1409
+ signal?.addEventListener("abort", () => resolve(), { once: true });
1410
+ });
1411
+ },
1412
+ };
1413
+ _setAdapterForToolForTest("claude", adapter);
1414
+
1415
+ const firstRun = runAgentSession(
1416
+ "sid-recovery-stop",
1417
+ "first prompt",
1418
+ platform,
1419
+ "chat-recovery-stop",
1420
+ 0,
1421
+ "claude",
1422
+ );
1423
+ await vi.waitFor(() => {
1424
+ expect(activePrompts.get("sid-recovery-stop")?.responseProgress).toBeDefined();
1425
+ });
1426
+ const progress = activePrompts.get("sid-recovery-stop")!.responseProgress!;
1427
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
1428
+ await firstRun;
1429
+
1430
+ expect(isSessionRunning("sid-recovery-stop")).toBe(true);
1431
+ expect(stopSession("sid-recovery-stop")).toBe(true);
1432
+ await vi.advanceTimersByTimeAsync(500);
1433
+
1434
+ expect(receivedPrompts).toHaveLength(1);
1435
+ expect(isSessionRunning("sid-recovery-stop")).toBe(false);
1436
+ });
1437
+ });
1438
+
1439
+ describe("unified display loop WeChat delta", () => {
1440
+ beforeEach(() => {
1441
+ vi.useFakeTimers();
1442
+ resetState();
1443
+ resetBindingState();
1444
+ mockStreamStates.clear();
1445
+ });
1446
+
1447
+ afterEach(() => {
1448
+ stopUnifiedDisplayLoop();
1449
+ resetBindingState();
1450
+ vi.useRealTimers();
1451
+ });
1452
+
1453
+ it("sends only new accumulated content when tool output arrives before an already-sent final reply", async () => {
1454
+ const platform = mockPlatform("wechat");
1455
+ platform.kind = "wechat";
1456
+ setSessionPlatform(platform);
1457
+
1458
+ bindChatToSession("sid-wechat", "chat-wechat");
1459
+ recordLastActiveChat("sid-wechat", "chat-wechat");
1460
+ sessionInfoMap.set("chat-wechat", {
1461
+ sessionId: "sid-wechat",
1462
+ turnCount: 1,
1463
+ lastContextTokens: 0,
1464
+ startTime: 0,
1465
+ tool: "claude",
1466
+ });
1467
+
1468
+ // 模拟 runAgentSession 创建的 WeChat display 条目
1469
+ displayCards.set("chat-wechat", {
1470
+ cardId: "",
1471
+ sequence: 0,
1472
+ cardBusy: false,
1473
+ cardCreatedAt: Date.now(),
1474
+ lastSentContent: "",
1475
+ streamErrorNotified: false,
1476
+ sessionId: "sid-wechat",
1477
+ turnCount: 1,
1478
+ dotCount: 0,
1479
+ });
1480
+
1481
+ mockStreamStates.set("sid-wechat", {
1482
+ accumulatedContent: "",
1483
+ finalReply: "partial reply",
1484
+ status: "running",
1485
+ });
1486
+ startUnifiedDisplayLoop();
1487
+ await vi.advanceTimersByTimeAsync(3000);
1488
+
1489
+ mockStreamStates.set("sid-wechat", {
1490
+ accumulatedContent: "tool output\n",
1491
+ finalReply: "partial reply",
1492
+ status: "running",
1493
+ });
1494
+ await vi.advanceTimersByTimeAsync(3000);
1495
+
1496
+ expect(platform.sendText).toHaveBeenNthCalledWith(
1497
+ 1,
1498
+ "chat-wechat",
1499
+ "partial reply",
1500
+ );
1501
+ expect(platform.sendText).toHaveBeenNthCalledWith(
1502
+ 2,
1503
+ "chat-wechat",
1504
+ "tool output",
1505
+ );
1506
+ });
1507
+
1508
+ });
1509
+
1510
+ describe("unified display loop activity status", () => {
1511
+ beforeEach(() => {
1512
+ vi.useFakeTimers();
1513
+ vi.setSystemTime(new Date("2026-07-16T08:00:00.000Z"));
1514
+ resetState();
1515
+ resetBindingState();
1516
+ mockStreamStates.clear();
1517
+ });
1518
+
1519
+ afterEach(() => {
1520
+ stopUnifiedDisplayLoop();
1521
+ resetBindingState();
1522
+ vi.useRealTimers();
1523
+ });
1524
+
1525
+ it("renders explicit activity and elapsed time alongside the liveness dots", async () => {
1526
+ const platform = mockPlatform("feishu");
1527
+ setSessionPlatform(platform);
1528
+
1529
+ bindChatToSession("sid-activity", "chat-activity");
1530
+ recordLastActiveChat("sid-activity", "chat-activity");
1531
+ sessionInfoMap.set("chat-activity", {
1532
+ sessionId: "sid-activity",
1533
+ turnCount: 1,
1534
+ lastContextTokens: 0,
1535
+ startTime: Date.now() - 12_000,
1536
+ tool: "codex",
1537
+ });
1538
+ displayCards.set("chat-activity", {
1539
+ cardId: "card-activity",
1540
+ sequence: 1,
1541
+ cardBusy: false,
1542
+ cardCreatedAt: Date.now(),
1543
+ lastSentContent: "",
1544
+ streamErrorNotified: false,
1545
+ sessionId: "sid-activity",
1546
+ turnCount: 1,
1547
+ dotCount: 0,
1548
+ });
1549
+ mockStreamStates.set("sid-activity", {
1550
+ accumulatedContent: "正在检查日志",
1551
+ finalReply: "",
1552
+ activity: {
1553
+ kind: "tool",
1554
+ startedAt: Date.now() - 12_000,
1555
+ toolName: "Shell",
1556
+ toolCount: 1,
1557
+ },
1558
+ status: "running",
1559
+ turnCount: 1,
1560
+ });
1561
+
1562
+ startUnifiedDisplayLoop();
1563
+ await vi.advanceTimersByTimeAsync(3_000);
1564
+
1565
+ const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
1566
+ expect(payload).toBeTypeOf("string");
1567
+ const card = JSON.parse(payload as string) as {
1568
+ header: { title: { content: string } };
1569
+ body: { elements: Array<{ tag: string; content?: string }> };
1570
+ };
1571
+ expect(card.header.title.content).toBe("正在执行 Shell · 15秒");
1572
+ expect(card.body.elements[0]?.content).toBe("正在检查日志\n。");
1573
+ });
1574
+ });
1575
+
1576
+ describe("unified display loop terminal card update", () => {
1577
+ beforeEach(() => {
1578
+ vi.useFakeTimers();
1579
+ resetState();
1580
+ resetBindingState();
1581
+ mockStreamStates.clear();
1582
+ });
1583
+
1584
+ afterEach(() => {
1585
+ stopUnifiedDisplayLoop();
1586
+ resetBindingState();
1587
+ vi.useRealTimers();
1588
+ });
1589
+
1590
+ it("shows a distinct auto-ended state and sends a warning even with an empty reply", async () => {
1591
+ const platform = mockPlatform("feishu");
1592
+ setSessionPlatform(platform);
1593
+
1594
+ bindChatToSession("sid-auto-ended", "chat-auto-ended");
1595
+ recordLastActiveChat("sid-auto-ended", "chat-auto-ended");
1596
+ sessionInfoMap.set("chat-auto-ended", {
1597
+ sessionId: "sid-auto-ended",
1598
+ turnCount: 1,
1599
+ lastContextTokens: 0,
1600
+ startTime: 0,
1601
+ tool: "cursor",
1602
+ });
1603
+ displayCards.set("chat-auto-ended", {
1604
+ cardId: "card-auto-ended",
1605
+ sequence: 1,
1606
+ cardBusy: false,
1607
+ cardCreatedAt: Date.now(),
1608
+ lastSentContent: "",
1609
+ streamErrorNotified: false,
1610
+ sessionId: "sid-auto-ended",
1611
+ turnCount: 1,
1612
+ dotCount: 0,
1613
+ });
1614
+ mockStreamStates.set("sid-auto-ended", {
1615
+ accumulatedContent: "",
1616
+ finalReply: "",
1617
+ status: "auto_ended",
1618
+ turnCount: 1,
1619
+ autoEndedAt: Date.now(),
1620
+ });
1621
+
1622
+ startUnifiedDisplayLoop();
1623
+ await vi.advanceTimersByTimeAsync(3_000);
1624
+
1625
+ const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
1626
+ const card = JSON.parse(payload as string) as {
1627
+ header: { title: { content: string }; template?: string };
1628
+ };
1629
+ expect(card.header.title.content).toBe("已自动结束 · 3分钟无新内容");
1630
+ expect(card.header.template).toBe("orange");
1631
+ expect(platform.sendText).toHaveBeenCalledWith(
1632
+ "chat-auto-ended",
1633
+ "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。本轮没有可发送的回复内容。",
1634
+ );
1635
+ expect(displayCards.has("chat-auto-ended")).toBe(false);
1636
+ });
1637
+
1638
+ it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
1639
+ const platform = mockPlatform("feishu");
1640
+ platform.cardUpdate = vi.fn(async () => {
1641
+ throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
1642
+ });
1643
+ setSessionPlatform(platform);
1644
+
1645
+ bindChatToSession("sid-terminal", "chat-terminal");
1646
+ recordLastActiveChat("sid-terminal", "chat-terminal");
1647
+ sessionInfoMap.set("chat-terminal", {
1648
+ sessionId: "sid-terminal",
1649
+ turnCount: 1,
1650
+ lastContextTokens: 0,
1651
+ startTime: 0,
1652
+ tool: "claude",
1653
+ });
1654
+ activePrompts.set("sid-terminal", {
1655
+ controller: new AbortController(),
1656
+ stopped: false,
1657
+ startTime: Date.now(),
1658
+ });
1659
+ displayCards.set("chat-terminal", {
1660
+ cardId: "card-terminal",
1661
+ sequence: 109,
1662
+ cardBusy: false,
1663
+ cardCreatedAt: Date.now(),
1664
+ lastSentContent: "",
1665
+ streamErrorNotified: false,
1666
+ sessionId: "sid-terminal",
1667
+ turnCount: 1,
1668
+ dotCount: 0,
1669
+ });
1670
+ mockStreamStates.set("sid-terminal", {
1671
+ accumulatedContent: "partial tool output",
1672
+ finalReply: "",
1673
+ status: "stopped",
1674
+ turnCount: 1,
1675
+ });
1676
+
1677
+ startUnifiedDisplayLoop();
1678
+ await vi.advanceTimersByTimeAsync(3000);
1679
+ await vi.advanceTimersByTimeAsync(3000);
1680
+
1681
+ expect(platform.cardUpdate).toHaveBeenCalledTimes(1);
1682
+ expect(platform.cardUpdate).toHaveBeenCalledWith(
1683
+ "card-terminal",
1684
+ expect.any(String),
1685
+ 110,
1686
+ );
1687
+ expect(displayCards.get("chat-terminal")?.sequence).toBe(110);
1688
+ });
1689
+
1690
+ it("keeps terminal display and retries final text when sending fails", async () => {
1689
1691
  vi.spyOn(console, "error").mockImplementation(() => {});
1690
1692
  const platform = mockPlatform("feishu");
1691
1693
  platform.cardUpdate = vi.fn(async () => {});
@@ -1876,17 +1878,25 @@ describe("getSessionStatus", () => {
1876
1878
  expect(status!.effort).not.toBeNull();
1877
1879
  // model 必为字符串(留空时显示 '(留空)',否则为环境变量值);不应是占位符
1878
1880
  expect(typeof status!.model).toBe("string");
1879
- expect(status!.model.length).toBeGreaterThan(0);
1880
- });
1881
-
1882
- it("Codex session status uses the per-session effort override", async () => {
1883
- mockSessionInfo("chat-codex", { sessionId: "sid-codex-effort", tool: "codex" });
1884
- setSessionEffortOverride("sid-codex-effort", "xhigh");
1885
-
1886
- const status = await getSessionStatus("chat-codex");
1887
-
1888
- expect(status!.effort).toBe("xhigh");
1889
- });
1881
+ expect(status!.model.length).toBeGreaterThan(0);
1882
+ });
1883
+
1884
+ it("Codex session status uses the per-session effort override", async () => {
1885
+ mockSessionInfo("chat-codex", { sessionId: "sid-codex-effort", tool: "codex" });
1886
+ setSessionEffortOverride("sid-codex-effort", "xhigh");
1887
+
1888
+ const status = await getSessionStatus("chat-codex");
1889
+
1890
+ expect(status!.effort).toBe("xhigh");
1891
+ });
1892
+
1893
+ it("CCC 会话:effort 默认取 config.ccc.effort,session override 优先", async () => {
1894
+ expect(getEffectiveEffortForTool("ccc")).toBe(config.ccc.effort);
1895
+ setSessionEffortOverride("sid-ccc-effort-default", "low");
1896
+ expect(getEffectiveEffortForTool("ccc", "sid-ccc-effort-default")).toBe("low");
1897
+ clearSessionEffortOverride("sid-ccc-effort-default");
1898
+ expect(getEffectiveEffortForTool("ccc", "sid-ccc-effort-default")).toBe(config.ccc.effort);
1899
+ });
1890
1900
 
1891
1901
  it("Cursor 会话:effort 恒为 null(卡片渲染时隐藏该行,避免显示无意义的 effort)", async () => {
1892
1902
  mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
@@ -1922,29 +1932,29 @@ describe("getSessionStatus", () => {
1922
1932
  expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
1923
1933
  });
1924
1934
 
1925
- it("Cursor 会话:adapter.getSessionInfo 抛错时降级为占位符(不阻塞 /state)", async () => {
1926
- mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
1927
- _setAdapterForToolForTest(
1928
- "cursor",
1929
- mockAdapter(() => {
1930
- throw new Error("simulated adapter failure");
1931
- }),
1932
- );
1933
- const status = await getSessionStatus("chat-cursor");
1934
- expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
1935
- expect(status!.effort).toBeNull();
1936
- });
1937
-
1938
- it("CCC Agent 会话:model 来自 ccc 配置且 effort 恒为 null", async () => {
1939
- mockSessionInfo("chat-ccc", { sessionId: "session-ccc", tool: "ccc" });
1940
-
1941
- const status = await getSessionStatus("chat-ccc");
1942
-
1943
- expect(status!.model).toBeTruthy();
1944
- expect(status!.model).not.toBe(UNKNOWN_MODEL_PLACEHOLDER);
1945
- expect(status!.effort).toBeNull();
1946
- });
1947
- });
1935
+ it("Cursor 会话:adapter.getSessionInfo 抛错时降级为占位符(不阻塞 /state)", async () => {
1936
+ mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
1937
+ _setAdapterForToolForTest(
1938
+ "cursor",
1939
+ mockAdapter(() => {
1940
+ throw new Error("simulated adapter failure");
1941
+ }),
1942
+ );
1943
+ const status = await getSessionStatus("chat-cursor");
1944
+ expect(status!.model).toBe(UNKNOWN_MODEL_PLACEHOLDER);
1945
+ expect(status!.effort).toBeNull();
1946
+ });
1947
+
1948
+ it("CCC Agent 会话:model 来自 ccc 配置且 effort 恒为 null", async () => {
1949
+ mockSessionInfo("chat-ccc", { sessionId: "session-ccc", tool: "ccc" });
1950
+
1951
+ const status = await getSessionStatus("chat-ccc");
1952
+
1953
+ expect(status!.model).toBeTruthy();
1954
+ expect(status!.model).not.toBe(UNKNOWN_MODEL_PLACEHOLDER);
1955
+ expect(status!.effort).toBeNull();
1956
+ });
1957
+ });
1948
1958
 
1949
1959
  describe("getAllSessionsStatus", () => {
1950
1960
  let registryFile = "";
@@ -1985,10 +1995,10 @@ describe("getAllSessionsStatus", () => {
1985
1995
 
1986
1996
  it("returns statuses from disk registry", async () => {
1987
1997
  await recordSessionRegistry({
1988
- chatId: "chat1",
1989
- sessionId: "s1",
1990
- tool: "claude",
1991
- chatType: "p2p",
1998
+ chatId: "chat1",
1999
+ sessionId: "s1",
2000
+ tool: "claude",
2001
+ chatType: "p2p",
1992
2002
  chatName: "test-chat-1",
1993
2003
  turnCount: 2,
1994
2004
  startTime: 1000,
@@ -2008,8 +2018,8 @@ describe("getAllSessionsStatus", () => {
2008
2018
 
2009
2019
  const result = await getAllSessionsStatus();
2010
2020
  expect(result).toHaveLength(2);
2011
- expect(result[0].chatId).toBe("chat1");
2012
- expect(result[0].chatType).toBe("p2p");
2021
+ expect(result[0].chatId).toBe("chat1");
2022
+ expect(result[0].chatType).toBe("p2p");
2013
2023
  // running=true in registry doesn't make it active — must be in activePrompts
2014
2024
  expect(result[0].active).toBe(false);
2015
2025
  expect(result[0].turnCount).toBe(2);