chatccc 0.2.205 → 0.2.207

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
@@ -318,7 +318,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
318
318
  | `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 秒 |
319
319
  | `allowInterrupt` | 是否允许新消息中断正在运行的任务;默认 false |
320
320
  | `*.enabled` | 是否启用对应 AI Agent |
321
- | `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具 |
321
+ | `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具;飞书私聊会在下一条普通消息到达时跟随变化并创建新的空会话 |
322
322
  | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
323
323
  | `cursor.avatarBatteryMode` | Cursor 头像电量显示来源:`apiPercent` 或 `onDemandUse` |
324
324
  | `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
@@ -330,7 +330,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
330
330
 
331
331
  ### 5. 开始使用
332
332
 
333
- **飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor` 或 `/new codex`,机器人会另外创建会话群,原私聊会话不受影响。
333
+ **飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor` 或 `/new codex`,机器人会另外创建会话群。
334
334
 
335
335
  **微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
336
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.205",
3
+ "version": "0.2.207",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -435,12 +435,13 @@ describe("buildSessionsCard", () => {
435
435
  expect(parsed.elements[2].text.content).toContain("/session 数字");
436
436
  });
437
437
 
438
- it("explains fixed Feishu private-session behavior without advertising switching", () => {
438
+ it("explains Feishu private-session default Agent following without advertising /session switching", () => {
439
439
  const card = buildSessionsCard([
440
440
  { sessionId: "abc123", chatName: "飞书私聊", chatId: "ou_private", active: false, turnCount: 2, elapsedSeconds: null, model: "Claude Opus 4.7", tool: "claude" },
441
441
  ], { fixedPrivateSession: true });
442
442
  const parsed = JSON.parse(card);
443
- expect(parsed.elements[2].text.content).toContain("固定的专属会话");
443
+ expect(parsed.elements[2].text.content).toContain("下一条普通消息");
444
+ expect(parsed.elements[2].text.content).toContain("新空会话");
444
445
  expect(parsed.elements[2].text.content).toContain("不支持 **/session** 切换");
445
446
  expect(parsed.elements[2].text.content).not.toContain("/session 数字");
446
447
  });
@@ -87,7 +87,7 @@ import {
87
87
  resetState,
88
88
  sessionInfoMap,
89
89
  } from "../session.ts";
90
- import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
90
+ import { activePrompts, dequeueMessage, resetBindingState } from "../session-chat-binding.ts";
91
91
  import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
92
92
  import { config } from "../config.ts";
93
93
 
@@ -342,6 +342,148 @@ describe("handleCommand WeChat processing ack", () => {
342
342
  expect(registry["feishu-p2p"]?.sessionId).toBe("sid-feishu-private");
343
343
  });
344
344
 
345
+ it("switches an idle Feishu p2p chat to a fresh session when the default Agent changes", async () => {
346
+ const platform = mockPlatform("feishu");
347
+ const oldPrompt = vi.fn(async function* () {
348
+ yield { type: "assistant" as const, blocks: [{ type: "text" as const, text: "old" }] };
349
+ });
350
+ _setAdapterForToolForTest("claude", {
351
+ ...mockAdapter("sid-old-claude"),
352
+ prompt: oldPrompt,
353
+ });
354
+
355
+ const createCursorSession = vi.fn(async () => ({ sessionId: "sid-new-cursor" }));
356
+ const cursorPrompt = vi.fn(async function* () {
357
+ yield { type: "assistant" as const, blocks: [{ type: "text" as const, text: "new" }] };
358
+ });
359
+ _setAdapterForToolForTest("cursor", {
360
+ ...mockAdapter("sid-new-cursor"),
361
+ displayName: "Cursor",
362
+ sessionDescPrefix: "Cursor Session:",
363
+ createSession: createCursorSession,
364
+ prompt: cursorPrompt,
365
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({ sessionId, cwd: homedir() }),
366
+ });
367
+ await recordSessionRegistry({
368
+ chatId: "feishu-p2p",
369
+ sessionId: "sid-old-claude",
370
+ tool: "claude",
371
+ chatType: "p2p",
372
+ chatName: "飞书私聊",
373
+ running: false,
374
+ });
375
+
376
+ const originalCursorEnabled = config.cursor.enabled;
377
+ try {
378
+ config.cursor.enabled = true;
379
+ config.claude.defaultAgent = false;
380
+ config.cursor.defaultAgent = true;
381
+
382
+ await handleCommand(platform, "/state", "feishu-p2p", "ou-user", Date.now(), "p2p");
383
+ expect(createCursorSession).not.toHaveBeenCalled();
384
+ expect((await loadSessionRegistryForBinding())["feishu-p2p"]?.sessionId).toBe("sid-old-claude");
385
+
386
+ await handleCommand(platform, "使用新的默认 Agent", "feishu-p2p", "ou-user", Date.now(), "p2p");
387
+
388
+ expect(createCursorSession).toHaveBeenCalledWith(homedir());
389
+ expect(oldPrompt).not.toHaveBeenCalled();
390
+ expect(cursorPrompt).toHaveBeenCalledWith(
391
+ "sid-new-cursor",
392
+ expect.stringContaining("使用新的默认 Agent"),
393
+ homedir(),
394
+ expect.any(AbortSignal),
395
+ expect.any(Object),
396
+ );
397
+ expect(platform.updateChatInfo).not.toHaveBeenCalled();
398
+ expect(platform.sendCard).toHaveBeenCalledWith(
399
+ "feishu-p2p",
400
+ "默认 Agent 已切换",
401
+ expect.stringContaining("Claude Code → Cursor"),
402
+ "green",
403
+ );
404
+
405
+ const registry = await loadSessionRegistryForBinding();
406
+ expect(registry["feishu-p2p"]).toMatchObject({
407
+ sessionId: "sid-new-cursor",
408
+ tool: "cursor",
409
+ chatType: "p2p",
410
+ turnCount: 1,
411
+ });
412
+ } finally {
413
+ config.cursor.enabled = originalCursorEnabled;
414
+ }
415
+ });
416
+
417
+ it("waits for a running Feishu p2p Agent before switching the queued message to the new default", async () => {
418
+ const platform = mockPlatform("feishu");
419
+ const createCursorSession = vi.fn(async () => ({ sessionId: "sid-cursor-after-wait" }));
420
+ const cursorPrompt = vi.fn(async function* () {
421
+ yield { type: "assistant" as const, blocks: [{ type: "text" as const, text: "new" }] };
422
+ });
423
+ _setAdapterForToolForTest("cursor", {
424
+ ...mockAdapter("sid-cursor-after-wait"),
425
+ displayName: "Cursor",
426
+ sessionDescPrefix: "Cursor Session:",
427
+ createSession: createCursorSession,
428
+ prompt: cursorPrompt,
429
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({ sessionId, cwd: homedir() }),
430
+ });
431
+ await recordSessionRegistry({
432
+ chatId: "feishu-p2p-wait",
433
+ sessionId: "sid-running-claude",
434
+ tool: "claude",
435
+ chatType: "p2p",
436
+ chatName: "飞书私聊",
437
+ running: true,
438
+ });
439
+ activePrompts.set("sid-running-claude", {
440
+ controller: new AbortController(),
441
+ stopped: false,
442
+ startTime: Date.now(),
443
+ });
444
+
445
+ const originalCursorEnabled = config.cursor.enabled;
446
+ try {
447
+ config.cursor.enabled = true;
448
+ config.claude.defaultAgent = false;
449
+ config.cursor.defaultAgent = true;
450
+
451
+ await handleCommand(platform, "等当前回复完成后处理", "feishu-p2p-wait", "ou-user", Date.now(), "p2p");
452
+
453
+ expect(createCursorSession).not.toHaveBeenCalled();
454
+ const queued = dequeueMessage("sid-running-claude");
455
+ expect(queued?.text).toContain("等当前回复完成后处理");
456
+ expect(platform.sendCard).toHaveBeenCalledWith(
457
+ "feishu-p2p-wait",
458
+ "Agent 切换等待中",
459
+ expect.stringContaining("完成后会切换到 Cursor"),
460
+ "blue",
461
+ );
462
+
463
+ activePrompts.delete("sid-running-claude");
464
+ await handleCommand(
465
+ platform,
466
+ queued!.text,
467
+ queued!.chatId,
468
+ queued!.openId,
469
+ queued!.msgTimestamp,
470
+ queued!.chatType,
471
+ queued!.traceId,
472
+ );
473
+
474
+ expect(createCursorSession).toHaveBeenCalledTimes(1);
475
+ expect(cursorPrompt).toHaveBeenCalledWith(
476
+ "sid-cursor-after-wait",
477
+ expect.stringContaining("等当前回复完成后处理"),
478
+ homedir(),
479
+ expect.any(AbortSignal),
480
+ expect.any(Object),
481
+ );
482
+ } finally {
483
+ config.cursor.enabled = originalCursorEnabled;
484
+ }
485
+ });
486
+
345
487
  it("creates the first Feishu p2p session in the OS user directory and sends the first prompt in place", async () => {
346
488
  const platform = mockPlatform("feishu");
347
489
  const createSession = vi.fn(async () => ({ sessionId: "sid-feishu-private" }));
@@ -551,7 +693,7 @@ describe("handleCommand WeChat processing ack", () => {
551
693
  expect(platform.sendCard).toHaveBeenCalledWith(
552
694
  "feishu-p2p",
553
695
  "/session",
554
- expect.stringContaining("飞书私聊使用固定的专属会话"),
696
+ expect.stringContaining("下一条普通消息时跟随默认 Agent"),
555
697
  "yellow",
556
698
  );
557
699
  expect(platform.updateChatInfo).not.toHaveBeenCalled();
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ hasResponseStalled,
5
+ observeResponseProgress,
6
+ } from "../response-stall.ts";
7
+
8
+ describe("response stall detection", () => {
9
+ it("starts tracking while responding even when the total character count is zero", () => {
10
+ const observation = observeResponseProgress(undefined, true, 0, 1_000);
11
+
12
+ expect(observation).toEqual({
13
+ totalChars: 0,
14
+ unchangedSince: 1_000,
15
+ });
16
+ });
17
+
18
+ it("auto-ends only after the same character count has lasted three minutes", () => {
19
+ const first = observeResponseProgress(undefined, true, 12, 1_000);
20
+ const unchanged = observeResponseProgress(first, true, 12, 90_000);
21
+
22
+ expect(unchanged).toBe(first);
23
+ expect(hasResponseStalled(unchanged, 180_999, 180_000)).toBe(false);
24
+ expect(hasResponseStalled(unchanged, 181_000, 180_000)).toBe(true);
25
+ });
26
+
27
+ it("restarts the timer whenever the total character count changes", () => {
28
+ const first = observeResponseProgress(undefined, true, 12, 1_000);
29
+ const changed = observeResponseProgress(first, true, 13, 150_000);
30
+
31
+ expect(changed).toEqual({
32
+ totalChars: 13,
33
+ unchangedSince: 150_000,
34
+ });
35
+ expect(hasResponseStalled(changed, 181_000, 180_000)).toBe(false);
36
+ });
37
+
38
+ it("clears tracking outside responding and starts a fresh window after returning", () => {
39
+ const first = observeResponseProgress(undefined, true, 0, 1_000);
40
+ const cleared = observeResponseProgress(first, false, 0, 100_000);
41
+ const resumed = observeResponseProgress(cleared, true, 0, 200_000);
42
+
43
+ expect(cleared).toBeUndefined();
44
+ expect(resumed).toEqual({
45
+ totalChars: 0,
46
+ unchangedSince: 200_000,
47
+ });
48
+ });
49
+ });
@@ -1,19 +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
- // mock stream-state 以支持在测试中控制累积长度
4
+ import { dirname, join } from "node:path";
5
+
6
+ const killProcessTreeMock = vi.hoisted(() => vi.fn(async () => {}));
7
+ vi.mock("../adapters/proc-tree-kill.ts", () => ({
8
+ killProcessTree: killProcessTreeMock,
9
+ }));
10
+
11
+ // mock stream-state 以支持在测试中控制累积长度
7
12
  const mockStreamStates = new Map<string, {
8
13
  accumulatedContent: string;
9
14
  finalReply: string;
10
15
  activity?: {
11
- kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
16
+ kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
12
17
  startedAt: number;
13
18
  toolName?: string;
14
19
  toolCount?: number;
15
20
  };
16
- status?: "running" | "done" | "stopped" | "error";
21
+ status?: "running" | "done" | "stopped" | "error" | "auto_ended";
22
+ autoEndedAt?: number;
17
23
  turnCount?: number;
18
24
  finalReplySentTurn?: number;
19
25
  finalReplySentAt?: number;
@@ -27,8 +33,9 @@ vi.mock("../stream-state.ts", () => ({
27
33
  accumulatedContent: state.accumulatedContent,
28
34
  finalReply: state.finalReply,
29
35
  activity: state.activity,
30
- finalReplySentTurn: state.finalReplySentTurn,
31
- finalReplySentAt: state.finalReplySentAt,
36
+ finalReplySentTurn: state.finalReplySentTurn,
37
+ finalReplySentAt: state.finalReplySentAt,
38
+ autoEndedAt: state.autoEndedAt,
32
39
  status: state.status ?? "running",
33
40
  chunkCount: 0,
34
41
  turnCount: state.turnCount ?? 0,
@@ -43,12 +50,13 @@ vi.mock("../stream-state.ts", () => ({
43
50
  accumulatedContent: string;
44
51
  finalReply: string;
45
52
  activity?: {
46
- kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "compacting";
53
+ kind: "starting" | "thinking" | "tool" | "processing" | "responding" | "searching" | "compacting";
47
54
  startedAt: number;
48
55
  toolName?: string;
49
56
  toolCount?: number;
50
57
  };
51
- status?: "running" | "done" | "stopped" | "error";
58
+ status?: "running" | "done" | "stopped" | "error" | "auto_ended";
59
+ autoEndedAt?: number;
52
60
  turnCount?: number;
53
61
  finalReplySentTurn?: number;
54
62
  finalReplySentAt?: number;
@@ -57,7 +65,8 @@ vi.mock("../stream-state.ts", () => ({
57
65
  accumulatedContent: state.accumulatedContent,
58
66
  finalReply: state.finalReply,
59
67
  activity: state.activity,
60
- status: state.status,
68
+ status: state.status,
69
+ autoEndedAt: state.autoEndedAt,
61
70
  turnCount: state.turnCount,
62
71
  finalReplySentTurn: state.finalReplySentTurn,
63
72
  finalReplySentAt: state.finalReplySentAt,
@@ -109,6 +118,10 @@ import {
109
118
  _resetProcessAliveForTest,
110
119
  _setProcessMonitorIntervalForTest,
111
120
  _resetProcessMonitorIntervalForTest,
121
+ _setResponseStallTimeoutForTest,
122
+ _resetResponseStallTimeoutForTest,
123
+ _setResponseStallCheckIntervalForTest,
124
+ _resetResponseStallCheckIntervalForTest,
112
125
  setSessionEffortOverride,
113
126
  } from "../session.ts";
114
127
  import {
@@ -119,9 +132,12 @@ import {
119
132
  getLastActiveChat,
120
133
  pickDisplayChat,
121
134
  resetBindingState,
122
- getChatsForSession,
123
- displayCards,
124
- } from "../session-chat-binding.ts";
135
+ getChatsForSession,
136
+ displayCards,
137
+ enqueueMessage,
138
+ setQueueConsumer,
139
+ isSessionRunning,
140
+ } from "../session-chat-binding.ts";
125
141
  import type { AccumulatorState } from "../session.ts";
126
142
  import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
127
143
  import type { PlatformAdapter } from "../platform-adapter.ts";
@@ -349,13 +365,15 @@ describe("runAgentSession process monitor", () => {
349
365
  _resetSessionRegistryFileForTest();
350
366
  _resetSessionToolsFileForTest();
351
367
  _clearAdapterCacheForTest();
352
- _resetProcessAliveForTest();
353
- _resetProcessMonitorIntervalForTest();
368
+ _resetProcessAliveForTest();
369
+ _resetProcessMonitorIntervalForTest();
370
+ _resetResponseStallTimeoutForTest();
371
+ _resetResponseStallCheckIntervalForTest();
354
372
  resetBindingState();
355
373
  vi.useRealTimers();
356
374
  });
357
375
 
358
- it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
376
+ it("marks the turn as error and sends a separate notice when the CLI process disappears", async () => {
359
377
  const platform = mockPlatform("feishu");
360
378
  setSessionPlatform(platform);
361
379
  bindChatToSession("sid-process", "chat-process");
@@ -450,7 +468,7 @@ describe("runAgentSession process monitor", () => {
450
468
  );
451
469
  });
452
470
 
453
- it("does not register an invisible progress card and sends the final text fallback", async () => {
471
+ it("does not register an invisible progress card and sends the final text fallback", async () => {
454
472
  vi.spyOn(console, "error").mockImplementation(() => {});
455
473
  const platform = mockPlatform("feishu");
456
474
  platform.cardCreate = vi.fn()
@@ -483,10 +501,77 @@ describe("runAgentSession process monitor", () => {
483
501
  "生成中卡片发送失败,结果将以文本形式发送。",
484
502
  );
485
503
  expect(platform.sendText).toHaveBeenCalledWith("chat-card-fallback", "final answer");
486
- expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
487
- });
488
-
489
- it("sends the stopped notice only after the prompt generator exits", async () => {
504
+ expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
505
+ });
506
+
507
+ it("consumes a queued message only after the previous turn finishes final delivery", async () => {
508
+ const platform = mockPlatform("feishu");
509
+ setSessionPlatform(platform);
510
+ bindChatToSession("sid-queue-finalize", "chat-queue-finalize");
511
+ recordLastActiveChat("sid-queue-finalize", "chat-queue-finalize");
512
+
513
+ let releaseFinalDelivery: (() => void) | undefined;
514
+ const finalDeliveryGate = new Promise<void>((resolve) => {
515
+ releaseFinalDelivery = resolve;
516
+ });
517
+ platform.sendText = vi.fn(async (_chatId, content) => {
518
+ if (content === "final answer") await finalDeliveryGate;
519
+ return true;
520
+ });
521
+
522
+ const adapter: ToolAdapter = {
523
+ displayName: "Claude Code",
524
+ sessionDescPrefix: "Claude Code Session:",
525
+ createSession: async () => ({ sessionId: "sid-queue-finalize" }),
526
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
527
+ closeSession: async () => {},
528
+ prompt: async function* () {
529
+ // 强制走最终文本发送路径,并用 gate 模拟该收尾步骤仍在进行。
530
+ displayCards.delete("chat-queue-finalize");
531
+ yield { type: "assistant", blocks: [{ type: "text", text: "final answer" }] };
532
+ },
533
+ };
534
+ _setAdapterForToolForTest("claude", adapter);
535
+
536
+ enqueueMessage("sid-queue-finalize", {
537
+ text: "queued prompt",
538
+ chatId: "chat-queue-finalize",
539
+ openId: "open-user",
540
+ msgTimestamp: Date.now(),
541
+ chatType: "p2p",
542
+ });
543
+ const consumeQueued = vi.fn();
544
+ setQueueConsumer(consumeQueued);
545
+
546
+ const runPromise = runAgentSession(
547
+ "sid-queue-finalize",
548
+ "first prompt",
549
+ platform,
550
+ "chat-queue-finalize",
551
+ Date.now(),
552
+ "claude",
553
+ );
554
+ await vi.waitFor(() => {
555
+ expect(platform.sendText).toHaveBeenCalledWith("chat-queue-finalize", "final answer");
556
+ });
557
+ expect(isSessionRunning("sid-queue-finalize")).toBe(true);
558
+
559
+ await vi.advanceTimersByTimeAsync(250);
560
+ expect(consumeQueued).not.toHaveBeenCalled();
561
+
562
+ releaseFinalDelivery?.();
563
+ await runPromise;
564
+ expect(isSessionRunning("sid-queue-finalize")).toBe(false);
565
+ await vi.advanceTimersByTimeAsync(200);
566
+ expect(consumeQueued).toHaveBeenCalledTimes(1);
567
+ expect(consumeQueued).toHaveBeenCalledWith(
568
+ platform,
569
+ expect.objectContaining({ text: "queued prompt", chatId: "chat-queue-finalize" }),
570
+ );
571
+ setQueueConsumer(() => {});
572
+ });
573
+
574
+ it("sends the stopped notice only after the prompt generator exits", async () => {
490
575
  const platform = mockPlatform("feishu");
491
576
  setSessionPlatform(platform);
492
577
  bindChatToSession("sid-stop-notice", "chat-stop-notice");
@@ -583,8 +668,108 @@ describe("runAgentSession process monitor", () => {
583
668
  });
584
669
  });
585
670
 
671
+ describe("runAgentSession response stall watchdog", () => {
672
+ let tempDir = "";
673
+
674
+ beforeEach(async () => {
675
+ vi.useFakeTimers();
676
+ resetState();
677
+ resetBindingState();
678
+ mockStreamStates.clear();
679
+ killProcessTreeMock.mockClear();
680
+ tempDir = await mkdtemp(join(tmpdir(), "chatccc-response-stall-"));
681
+ _setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
682
+ _setSessionToolsFileForTest(join(tempDir, "session-tools.json"));
683
+ });
684
+
685
+ afterEach(async () => {
686
+ _resetSessionRegistryFileForTest();
687
+ _resetSessionToolsFileForTest();
688
+ _clearAdapterCacheForTest();
689
+ _resetProcessAliveForTest();
690
+ _resetResponseStallTimeoutForTest();
691
+ _resetResponseStallCheckIntervalForTest();
692
+ resetBindingState();
693
+ vi.useRealTimers();
694
+ if (tempDir) await rm(tempDir, { recursive: true, force: true });
695
+ });
696
+
697
+ it("auto-ends every Agent after three minutes of unchanged reply characters, including zero", async () => {
698
+ vi.setSystemTime(0);
699
+ _setResponseStallTimeoutForTest(180_000);
700
+ _setResponseStallCheckIntervalForTest(1_000);
701
+ _setProcessAliveForTest(() => true);
702
+
703
+ const platform = mockPlatform("feishu");
704
+ setSessionPlatform(platform);
705
+ bindChatToSession("sid-response-stall", "chat-response-stall");
706
+ recordLastActiveChat("sid-response-stall", "chat-response-stall");
707
+
708
+ const closeSession = vi.fn();
709
+ const adapter: ToolAdapter = {
710
+ displayName: "Any Agent",
711
+ sessionDescPrefix: "Agent Session:",
712
+ createSession: async () => ({ sessionId: "sid-response-stall" }),
713
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
714
+ closeSession: async () => {},
715
+ prompt: async function* (
716
+ _sid: string,
717
+ _text: string,
718
+ _cwd: string,
719
+ signal?: AbortSignal,
720
+ options?: ToolPromptOptions,
721
+ ) {
722
+ options?.onSessionCreated?.(closeSession);
723
+ options?.onProcessStart?.({ pid: 4242 });
724
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
725
+ await new Promise<void>((resolve) => {
726
+ if (signal?.aborted) {
727
+ resolve();
728
+ return;
729
+ }
730
+ signal?.addEventListener("abort", () => resolve(), { once: true });
731
+ });
732
+ },
733
+ };
734
+ _setAdapterForToolForTest("claude", adapter);
735
+
736
+ const runPromise = runAgentSession(
737
+ "sid-response-stall",
738
+ "prompt",
739
+ platform,
740
+ "chat-response-stall",
741
+ Date.now(),
742
+ "claude",
743
+ );
744
+
745
+ await vi.waitFor(() => {
746
+ expect(activePrompts.get("sid-response-stall")?.responseProgress).toEqual({
747
+ totalChars: 0,
748
+ unchangedSince: expect.any(Number),
749
+ });
750
+ });
751
+
752
+ const progress = activePrompts.get("sid-response-stall")!.responseProgress!;
753
+ const remainingBeforeBoundary = progress.unchangedSince + 180_000 - Date.now() - 1;
754
+ await vi.advanceTimersByTimeAsync(remainingBeforeBoundary);
755
+ expect(activePrompts.has("sid-response-stall")).toBe(true);
756
+
757
+ await vi.advanceTimersByTimeAsync(1_001);
758
+ await runPromise;
759
+
760
+ expect(closeSession).toHaveBeenCalledTimes(1);
761
+ expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
762
+ expect(activePrompts.has("sid-response-stall")).toBe(false);
763
+ expect(mockStreamStates.get("sid-response-stall")).toMatchObject({
764
+ status: "auto_ended",
765
+ finalReply: "",
766
+ autoEndedAt: expect.any(Number),
767
+ });
768
+ });
769
+ });
770
+
586
771
  describe("unified display loop WeChat delta", () => {
587
- beforeEach(() => {
772
+ beforeEach(() => {
588
773
  vi.useFakeTimers();
589
774
  resetState();
590
775
  resetBindingState();
@@ -651,6 +836,7 @@ describe("unified display loop WeChat delta", () => {
651
836
  "tool output",
652
837
  );
653
838
  });
839
+
654
840
  });
655
841
 
656
842
  describe("unified display loop activity status", () => {
@@ -727,13 +913,61 @@ describe("unified display loop terminal card update", () => {
727
913
  mockStreamStates.clear();
728
914
  });
729
915
 
730
- afterEach(() => {
916
+ afterEach(() => {
731
917
  stopUnifiedDisplayLoop();
732
918
  resetBindingState();
733
- vi.useRealTimers();
734
- });
735
-
736
- it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
919
+ vi.useRealTimers();
920
+ });
921
+
922
+ it("shows a distinct auto-ended state and sends a warning even with an empty reply", async () => {
923
+ const platform = mockPlatform("feishu");
924
+ setSessionPlatform(platform);
925
+
926
+ bindChatToSession("sid-auto-ended", "chat-auto-ended");
927
+ recordLastActiveChat("sid-auto-ended", "chat-auto-ended");
928
+ sessionInfoMap.set("chat-auto-ended", {
929
+ sessionId: "sid-auto-ended",
930
+ turnCount: 1,
931
+ lastContextTokens: 0,
932
+ startTime: 0,
933
+ tool: "cursor",
934
+ });
935
+ displayCards.set("chat-auto-ended", {
936
+ cardId: "card-auto-ended",
937
+ sequence: 1,
938
+ cardBusy: false,
939
+ cardCreatedAt: Date.now(),
940
+ lastSentContent: "",
941
+ streamErrorNotified: false,
942
+ sessionId: "sid-auto-ended",
943
+ turnCount: 1,
944
+ dotCount: 0,
945
+ });
946
+ mockStreamStates.set("sid-auto-ended", {
947
+ accumulatedContent: "",
948
+ finalReply: "",
949
+ status: "auto_ended",
950
+ turnCount: 1,
951
+ autoEndedAt: Date.now(),
952
+ });
953
+
954
+ startUnifiedDisplayLoop();
955
+ await vi.advanceTimersByTimeAsync(3_000);
956
+
957
+ const payload = vi.mocked(platform.cardUpdate).mock.calls[0]?.[1];
958
+ const card = JSON.parse(payload as string) as {
959
+ header: { title: { content: string }; template?: string };
960
+ };
961
+ expect(card.header.title.content).toBe("已自动结束 · 3分钟无新内容");
962
+ expect(card.header.template).toBe("orange");
963
+ expect(platform.sendText).toHaveBeenCalledWith(
964
+ "chat-auto-ended",
965
+ "⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。本轮没有可发送的回复内容。",
966
+ );
967
+ expect(displayCards.has("chat-auto-ended")).toBe(false);
968
+ });
969
+
970
+ it("does not repeat the same terminal CardKit sequence while the prompt is still active", async () => {
737
971
  const platform = mockPlatform("feishu");
738
972
  platform.cardUpdate = vi.fn(async () => {
739
973
  throw new Error("CardKit update: [300317] ErrMsg: sequence number compare failed; ");
package/src/cards.ts CHANGED
@@ -344,7 +344,7 @@ export function buildSessionsCard(sessions: Array<{
344
344
  header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
345
345
  elements: [
346
346
  { tag: "div", text: { tag: "lark_md", content: fixedPrivateSession
347
- ? `当前没有会话记录。\n\n直接发送普通消息即可创建飞书私聊专属 ${defaultToolLabel} 会话;发送 **/new**、**/new claude**、**/new cursor** 或 **/new codex** 会另外创建会话群。`
347
+ ? `当前没有会话记录。\n\n直接发送普通消息即可创建飞书私聊专属 ${defaultToolLabel} 会话;默认 Agent 变化后,下一条普通消息会创建对应 Agent 的新空会话。发送 **/new**、**/new claude**、**/new cursor** 或 **/new codex** 会另外创建会话群。`
348
348
  : `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。\n创建后可在任意会话群内发送 **/sessions** 查看列表,用 **/session 数字** 切换会话。` } },
349
349
  { tag: "hr" },
350
350
  { tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
@@ -387,7 +387,7 @@ export function buildSessionsCard(sessions: Array<{
387
387
  { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
388
388
  { tag: "hr" },
389
389
  { tag: "div", text: { tag: "lark_md", content: fixedPrivateSession
390
- ? "当前飞书私聊使用固定的专属会话;发送 **/newh** 可在私聊中原地重置。群聊会话请回到对应群聊继续,私聊不支持 **/session** 切换。"
390
+ ? "当前飞书私聊使用专属会话;默认 Agent 变化后,下一条普通消息会自动创建对应 Agent 的新空会话。发送 **/newh** 可在私聊中原地重置。群聊会话请回到对应群聊继续,私聊不支持 **/session** 切换。"
391
391
  : "在会话群内发送 **/newh** 可重置当前会话(创建新 Session,保留工作目录和群聊)。\n发送 **/session 数字**(如 `/session 1`)可将当前群聊切换到列表中对应编号的会话。" } },
392
392
  { tag: "hr" },
393
393
  {