chatccc 0.2.206 → 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.206",
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();
@@ -132,9 +132,12 @@ import {
132
132
  getLastActiveChat,
133
133
  pickDisplayChat,
134
134
  resetBindingState,
135
- getChatsForSession,
136
- displayCards,
137
- } from "../session-chat-binding.ts";
135
+ getChatsForSession,
136
+ displayCards,
137
+ enqueueMessage,
138
+ setQueueConsumer,
139
+ isSessionRunning,
140
+ } from "../session-chat-binding.ts";
138
141
  import type { AccumulatorState } from "../session.ts";
139
142
  import type { ToolAdapter, ToolPromptOptions, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
140
143
  import type { PlatformAdapter } from "../platform-adapter.ts";
@@ -465,7 +468,7 @@ describe("runAgentSession process monitor", () => {
465
468
  );
466
469
  });
467
470
 
468
- 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 () => {
469
472
  vi.spyOn(console, "error").mockImplementation(() => {});
470
473
  const platform = mockPlatform("feishu");
471
474
  platform.cardCreate = vi.fn()
@@ -498,10 +501,77 @@ describe("runAgentSession process monitor", () => {
498
501
  "生成中卡片发送失败,结果将以文本形式发送。",
499
502
  );
500
503
  expect(platform.sendText).toHaveBeenCalledWith("chat-card-fallback", "final answer");
501
- expect(mockStreamStates.get("sid-card-fallback")?.finalReplySentTurn).toBe(1);
502
- });
503
-
504
- 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 () => {
505
575
  const platform = mockPlatform("feishu");
506
576
  setSessionPlatform(platform);
507
577
  bindChatToSession("sid-stop-notice", "chat-stop-notice");
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
  {
@@ -411,10 +411,134 @@ function shouldSendWechatProcessingAck(
411
411
  return platform.kind === "wechat" && chatType === "p2p" && !isCommandText;
412
412
  }
413
413
 
414
- /** 飞书私聊是固定会话容器;显式 /new 才创建独立群聊。 */
414
+ /** 飞书私聊是专属会话容器;显式 /new 才创建独立群聊。 */
415
415
  function isFeishuP2p(platform: PlatformAdapter, chatType: string): boolean {
416
416
  return chatType === "p2p" && platform.kind === "feishu";
417
417
  }
418
+
419
+ interface FeishuP2pRegistryRecord {
420
+ sessionId: string;
421
+ tool: string;
422
+ chatType?: string;
423
+ chatName?: string;
424
+ }
425
+
426
+ type FeishuP2pAgentResolution =
427
+ | { kind: "ready"; sessionId: string; tool: string }
428
+ | { kind: "waiting"; sessionId: string; tool: string; desiredTool: AgentTool }
429
+ | { kind: "error"; previousTool: string; desiredTool: AgentTool; error: Error };
430
+
431
+ // 同一个飞书私聊可能短时间收到多条消息。切换期间共享同一个 Promise,避免
432
+ // 为同一次默认 Agent 变化创建多个空会话。
433
+ const feishuP2pAgentSwitches = new Map<string, Promise<FeishuP2pAgentResolution>>();
434
+
435
+ async function resolveFeishuP2pAgent(
436
+ platform: PlatformAdapter,
437
+ chatId: string,
438
+ text: string,
439
+ record: FeishuP2pRegistryRecord,
440
+ traceId: string,
441
+ ): Promise<FeishuP2pAgentResolution> {
442
+ const desiredTool = resolveDefaultAgentTool();
443
+ if (record.tool === desiredTool) {
444
+ return { kind: "ready", sessionId: record.sessionId, tool: record.tool };
445
+ }
446
+
447
+ if (isSessionRunning(record.sessionId)) {
448
+ return {
449
+ kind: "waiting",
450
+ sessionId: record.sessionId,
451
+ tool: record.tool,
452
+ desiredTool,
453
+ };
454
+ }
455
+
456
+ const existingSwitch = feishuP2pAgentSwitches.get(chatId);
457
+ if (existingSwitch) return existingSwitch;
458
+
459
+ const switchOperation = (async (): Promise<FeishuP2pAgentResolution> => {
460
+ try {
461
+ // 异步创建开始前重新读取一次,防止另一个请求刚完成了绑定切换。
462
+ const latestRecord = (await loadSessionRegistryForBinding())[chatId];
463
+ if (!latestRecord?.sessionId || !latestRecord.tool || latestRecord.chatType !== "p2p") {
464
+ return {
465
+ kind: "error",
466
+ previousTool: record.tool,
467
+ desiredTool,
468
+ error: new Error("飞书私聊绑定在切换前已发生变化"),
469
+ };
470
+ }
471
+ if (latestRecord.tool === desiredTool) {
472
+ return { kind: "ready", sessionId: latestRecord.sessionId, tool: latestRecord.tool };
473
+ }
474
+ if (isSessionRunning(latestRecord.sessionId)) {
475
+ return {
476
+ kind: "waiting",
477
+ sessionId: latestRecord.sessionId,
478
+ tool: latestRecord.tool,
479
+ desiredTool,
480
+ };
481
+ }
482
+
483
+ const cwd = homedir();
484
+ const init = await initClaudeSession(desiredTool, cwd);
485
+ const chatName = sessionChatName(text.slice(0, 10) || "私聊会话", cwd);
486
+ const switchResult = await switchChatBinding({
487
+ chatId,
488
+ chatType: "p2p",
489
+ oldSessionId: latestRecord.sessionId,
490
+ newSessionId: init.sessionId,
491
+ tool: desiredTool,
492
+ chatName,
493
+ newDescription: `${sessionPrefixForTool(desiredTool)} ${init.sessionId}`,
494
+ updateChatInfoFn: (id, name, desc) => platform.updateChatInfo(id, name, desc),
495
+ });
496
+ if (!switchResult.ok) {
497
+ return {
498
+ kind: "error",
499
+ previousTool: latestRecord.tool,
500
+ desiredTool,
501
+ error: switchResult.error ?? new Error("更新飞书私聊绑定失败"),
502
+ };
503
+ }
504
+
505
+ const previousLabel = toolDisplayName(latestRecord.tool);
506
+ const desiredLabel = toolDisplayName(desiredTool);
507
+ logTrace(traceId, "BRANCH", {
508
+ reason: "switch_feishu_p2p_default_agent",
509
+ chatId,
510
+ oldSessionId: latestRecord.sessionId,
511
+ newSessionId: init.sessionId,
512
+ oldTool: latestRecord.tool,
513
+ newTool: desiredTool,
514
+ });
515
+ await platform.sendCard(
516
+ chatId,
517
+ "默认 Agent 已切换",
518
+ `检测到默认 Agent 已变化:**${previousLabel} → ${desiredLabel}**。\n\n已创建新的空白 ${desiredLabel} 私聊会话,并从本条消息开始使用。`,
519
+ "green",
520
+ ).catch(() => {});
521
+ platform.setChatAvatar(chatId, desiredTool, "new").catch(() => {});
522
+ return { kind: "ready", sessionId: init.sessionId, tool: desiredTool };
523
+ } catch (err) {
524
+ return {
525
+ kind: "error",
526
+ previousTool: record.tool,
527
+ desiredTool,
528
+ error: err as Error,
529
+ };
530
+ }
531
+ })();
532
+
533
+ feishuP2pAgentSwitches.set(chatId, switchOperation);
534
+ try {
535
+ return await switchOperation;
536
+ } finally {
537
+ if (feishuP2pAgentSwitches.get(chatId) === switchOperation) {
538
+ feishuP2pAgentSwitches.delete(chatId);
539
+ }
540
+ }
541
+ }
418
542
 
419
543
  /** 检测当前进程是否从 npm 全局安装启动 */
420
544
  function isRunningFromGlobalNpm(): boolean {
@@ -532,7 +656,7 @@ export async function handleCommand(
532
656
  "配置已重新加载。",
533
657
  `默认 Agent: ${toolDisplayName(result.defaultAgent)}`,
534
658
  `配置文件: ${result.configPath}`,
535
- "后续新会话会使用最新配置;正在生成的会话不会被中断。",
659
+ "后续新会话会使用最新配置;飞书私聊会在下一条普通消息时跟随默认 Agent,正在生成的会话不会被中断。",
536
660
  ].join("\n"),
537
661
  ).catch(() => {});
538
662
  logTrace(tid, "DONE", { outcome: "reload", defaultAgent: result.defaultAgent });
@@ -965,11 +1089,12 @@ export async function handleCommand(
965
1089
 
966
1090
  // 检测会话上下文:群聊从 description 获取,飞书/微信私聊都从
967
1091
  // session-registry 获取。私聊 chatId 是稳定容器,进程重启后仍恢复绑定。
968
- let sessionId: string | null = null;
969
- let descriptionTool: string | null = null;
970
- let toolLabel: string | null = null;
971
- let chatInfo: Awaited<ReturnType<PlatformAdapter["getChatInfo"]>> | undefined;
972
- let description: string | undefined;
1092
+ let sessionId: string | null = null;
1093
+ let descriptionTool: string | null = null;
1094
+ let toolLabel: string | null = null;
1095
+ let pendingFeishuP2pDefaultTool: AgentTool | null = null;
1096
+ let chatInfo: Awaited<ReturnType<PlatformAdapter["getChatInfo"]>> | undefined;
1097
+ let description: string | undefined;
973
1098
 
974
1099
  if (chatType !== "p2p") {
975
1100
  try {
@@ -1010,9 +1135,38 @@ export async function handleCommand(
1010
1135
  oldSessionId: record.sessionId,
1011
1136
  });
1012
1137
  } else if (record && record.sessionId && record.tool) {
1013
- sessionId = record.sessionId;
1014
- descriptionTool = record.tool;
1015
- toolLabel = toolDisplayName(descriptionTool);
1138
+ let resolvedRecord: { sessionId: string; tool: string } = record;
1139
+ if (platform.kind === "feishu" && !isCommandText && record.chatType === "p2p") {
1140
+ const resolution = await resolveFeishuP2pAgent(platform, chatId, text, record, tid);
1141
+ if (resolution.kind === "error") {
1142
+ const previousLabel = toolDisplayName(resolution.previousTool);
1143
+ const desiredLabel = toolDisplayName(resolution.desiredTool);
1144
+ console.error(
1145
+ `[${ts()}] [P2P-SWITCH] ${previousLabel} -> ${desiredLabel} FAIL: ${resolution.error.message}`,
1146
+ );
1147
+ logTrace(tid, "DONE", {
1148
+ outcome: "switch_feishu_p2p_default_agent_fail",
1149
+ oldTool: resolution.previousTool,
1150
+ newTool: resolution.desiredTool,
1151
+ error: resolution.error.message,
1152
+ });
1153
+ await platform.sendCard(
1154
+ chatId,
1155
+ "Agent 切换失败",
1156
+ `无法从 ${previousLabel} 切换到 ${desiredLabel}:\n${resolution.error.message}`,
1157
+ "red",
1158
+ ).catch(() => {});
1159
+ return;
1160
+ }
1161
+ resolvedRecord = resolution;
1162
+ if (resolution.kind === "waiting") {
1163
+ pendingFeishuP2pDefaultTool = resolution.desiredTool;
1164
+ }
1165
+ }
1166
+
1167
+ sessionId = resolvedRecord.sessionId;
1168
+ descriptionTool = resolvedRecord.tool;
1169
+ toolLabel = toolDisplayName(descriptionTool);
1016
1170
  // 确保内存状态在冷启动后恢复;bindChatToSession 是幂等的。
1017
1171
  if (!sessionInfoMap.has(chatId)) {
1018
1172
  sessionInfoMap.set(chatId, {
@@ -1389,7 +1543,7 @@ export async function handleCommand(
1389
1543
  await platform.sendCard(
1390
1544
  chatId,
1391
1545
  "/session",
1392
- "飞书私聊使用固定的专属会话,不能切换到历史群聊会话。请回到对应群聊继续,或发送 /new 新建群聊。",
1546
+ "飞书私聊不能通过 /session 切换到历史群聊会话;它会在下一条普通消息时跟随默认 Agent。请回到对应群聊继续,或发送 /new 新建群聊。",
1393
1547
  "yellow",
1394
1548
  );
1395
1549
  logTrace(tid, "DONE", { outcome: "session_switch_disabled_feishu_p2p" });
@@ -1762,8 +1916,19 @@ export async function handleCommand(
1762
1916
  if (platform.kind === "wechat") {
1763
1917
  await platform.sendText(chatId, "当前会话正在生成中,你的消息已进入缓存队列,生成完成后会立即处理。发送 /cancel 可取消缓存。").catch(() => {});
1764
1918
  } else {
1765
- await platform.sendRawCard(chatId, buildQueuedCard(text)).catch(() => {});
1766
- }
1919
+ if (isFeishuP2p(platform, chatType) && pendingFeishuP2pDefaultTool) {
1920
+ const currentLabel = toolDisplayName(descriptionTool);
1921
+ const desiredLabel = toolDisplayName(pendingFeishuP2pDefaultTool);
1922
+ await platform.sendCard(
1923
+ chatId,
1924
+ "Agent 切换等待中",
1925
+ `当前 ${currentLabel} 正在生成;完成后会切换到 ${desiredLabel},并用新的空会话处理这条消息。\n\n发送 **/cancel** 可取消缓存。`,
1926
+ "blue",
1927
+ ).catch(() => {});
1928
+ } else {
1929
+ await platform.sendRawCard(chatId, buildQueuedCard(text)).catch(() => {});
1930
+ }
1931
+ }
1767
1932
  } else {
1768
1933
  logTrace(tid, "QUEUE_FULL", { sessionId });
1769
1934
  console.log(
@@ -53,10 +53,22 @@ export function hasChatsForSession(sessionId: string): boolean {
53
53
  return (sessionChatsMap.get(sessionId)?.size ?? 0) > 0;
54
54
  }
55
55
 
56
- /** 检查 sessionId 是否正被其他 chatId 使用(有活跃 prompt) */
57
- export function isSessionRunning(sessionId: string): boolean {
58
- return activePrompts.has(sessionId);
59
- }
56
+ // Agent generator 退出后,最终卡片、registry、头像等仍可能在异步收尾。该阶段
57
+ // 也必须阻止下一轮提前进入,否则旧会话的落盘可能覆盖新会话绑定。
58
+ const finalizingSessions = new Set<string>();
59
+
60
+ /** 检查 sessionId 是否有活跃 prompt 或正在完成本轮异步收尾。 */
61
+ export function isSessionRunning(sessionId: string): boolean {
62
+ return activePrompts.has(sessionId) || finalizingSessions.has(sessionId);
63
+ }
64
+
65
+ export function markSessionFinalizing(sessionId: string): void {
66
+ finalizingSessions.add(sessionId);
67
+ }
68
+
69
+ export function clearSessionFinalizing(sessionId: string): void {
70
+ finalizingSessions.delete(sessionId);
71
+ }
60
72
 
61
73
  // ---------------------------------------------------------------------------
62
74
  // activePrompts: sessionId → 活跃 prompt 控制
@@ -231,8 +243,9 @@ export function resetBindingState(): void {
231
243
  if (prompt.processMonitor) clearInterval(prompt.processMonitor);
232
244
  if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
233
245
  }
234
- activePrompts.clear();
235
- queuedMessages.clear();
246
+ activePrompts.clear();
247
+ finalizingSessions.clear();
248
+ queuedMessages.clear();
236
249
  displayCards.clear();
237
250
  if (unifiedDisplayLoopHandle !== null) {
238
251
  clearInterval(unifiedDisplayLoopHandle);
package/src/session.ts CHANGED
@@ -72,10 +72,12 @@ import {
72
72
  pickDisplayChat,
73
73
  dequeueMessage,
74
74
  consumeQueuedMessage,
75
- cancelQueuedMessage,
76
- setQueuePreservedChat,
77
- consumeQueuePreservedChat,
78
- } from "./session-chat-binding.ts";
75
+ cancelQueuedMessage,
76
+ setQueuePreservedChat,
77
+ consumeQueuePreservedChat,
78
+ markSessionFinalizing,
79
+ clearSessionFinalizing,
80
+ } from "./session-chat-binding.ts";
79
81
 
80
82
  async function sendFinalReplyTextOnce(
81
83
  platform: PlatformAdapter,
@@ -1377,9 +1379,11 @@ export async function runAgentSession(
1377
1379
  const autoEndedAt = prompt?.autoEndedAt;
1378
1380
  clearPromptResponseStallMonitor(sessionId);
1379
1381
  clearPromptProcessMonitor(sessionId);
1382
+ markSessionFinalizing(sessionId);
1380
1383
  activePrompts.delete(sessionId);
1381
-
1382
- // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1384
+
1385
+ try {
1386
+ // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
1383
1387
  // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1384
1388
  // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1385
1389
  // 运行中并更新旧卡片,而不是新建卡片。
@@ -1423,35 +1427,7 @@ export async function runAgentSession(
1423
1427
  ...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
1424
1428
  });
1425
1429
 
1426
- // 消费队列中的缓存消息(异步,不阻塞后续清理)
1427
- // 用户 /stop 后应丢弃队列消息,避免用户停止后又自动开始新轮
1428
- if (wasStopped) {
1429
- const discarded = dequeueMessage(sessionId);
1430
- if (discarded) {
1431
- console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
1432
- }
1433
- } else {
1434
- const queued = dequeueMessage(sessionId);
1435
- if (queued) {
1436
- // 队列消息可能来自其他群,保存当前 display chat 避免 display loop 被
1437
- // 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
1438
- // 用保存的 chat 替代 queued.chatId 作为 display 目标)
1439
- const preservedChat = getLastActiveChat(sessionId);
1440
- if (preservedChat && preservedChat !== queued.chatId) {
1441
- setQueuePreservedChat(sessionId, preservedChat);
1442
- }
1443
- console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
1444
- // setTimeout 而非 setImmediate:给 display loop 的 setInterval
1445
- // 足够时间读到 "done" 状态并终结旧卡片,避免新轮更新旧卡片的 bug。
1446
- // setImmediate 在 check 阶段触发早于下一个 timers 阶段,
1447
- // display loop (setInterval) 还没机会读到 "done" 就被新 "running" 覆盖。
1448
- setTimeout(() => {
1449
- consumeQueuedMessage(platform, queued);
1450
- }, 200);
1451
- }
1452
- }
1453
-
1454
- // display loop 下一轮会读到最终状态并发送消息
1430
+ // display loop 下一轮会读到最终状态并发送消息
1455
1431
 
1456
1432
  if (wasStopped) {
1457
1433
  for (const cid of getChatsForSession(sessionId)) {
@@ -1542,11 +1518,43 @@ export async function runAgentSession(
1542
1518
  }
1543
1519
  platform.setChatAvatar(active2, tool, "idle").catch(() => {});
1544
1520
  }
1545
- console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1546
- if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1547
- }
1548
- }
1549
- }
1521
+ console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1522
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1523
+ }
1524
+
1525
+ // 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
1526
+ // 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
1527
+ let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
1528
+ if (wasStopped) {
1529
+ const discarded = dequeueMessage(sessionId);
1530
+ if (discarded) {
1531
+ console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
1532
+ }
1533
+ } else {
1534
+ queuedForConsumption = dequeueMessage(sessionId);
1535
+ }
1536
+
1537
+ if (queuedForConsumption) {
1538
+ const queued = queuedForConsumption;
1539
+ // 队列消息可能来自其他群,保存当前 display chat 避免 display loop 被
1540
+ // 错误重定向(runAgentSession 会 consumeQueuePreservedChat 并在存在时
1541
+ // 用保存的 chat 替代 queued.chatId 作为 display 目标)。
1542
+ const preservedChat = getLastActiveChat(sessionId);
1543
+ if (preservedChat && preservedChat !== queued.chatId) {
1544
+ setQueuePreservedChat(sessionId, preservedChat);
1545
+ }
1546
+ console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
1547
+ // setTimeout 而非 setImmediate:给 display loop 的 setInterval
1548
+ // 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
1549
+ setTimeout(() => {
1550
+ consumeQueuedMessage(platform, queued);
1551
+ }, 200);
1552
+ }
1553
+ } finally {
1554
+ clearSessionFinalizing(sessionId);
1555
+ }
1556
+ }
1557
+ }
1550
1558
 
1551
1559
  // ---------------------------------------------------------------------------
1552
1560
  // startUnifiedDisplayLoop — 全局统一 display 循环,遍历 displayCards 更新卡片