From 3e956a6bbd96fabd45118eeed447f3437d34946b Mon Sep 17 00:00:00 2001 From: daizhijia Date: Tue, 28 Jul 2026 18:25:46 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(lark):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=A5=E7=BE=A4=E4=B8=BB=E5=8A=A8=E5=BC=80=E5=B7=A5=E8=AF=9D?= =?UTF-8?q?=E9=A2=98=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 普通群 shared 模式缺少首条消息锚点, 导致主动开工回复平铺。保留 chat-scope 会话复用语义, 并让首轮输出稳定归入同一话题。 --- src/daemon.ts | 34 ++- test/group-join-shared-routing.test.ts | 276 +++++++++++++++++++++++++ 2 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 test/group-join-shared-routing.test.ts diff --git a/src/daemon.ts b/src/daemon.ts index c80775571..80932bbb6 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -67,6 +67,7 @@ import { type VcMeetingConsumerProfileConfig, } from './bot-registry.js'; import { setDisplayNameRefresher, findConfigField, applyConfigField } from './services/bot-config-store.js'; +import { resolveRegularGroupMode } from './services/chat-reply-mode-store.js'; import { renameBotOnOpenPlatform, changeBotAvatarOnOpenPlatform } from './services/open-platform-rename.js'; import { migrateSandboxConfigAtStartup } from './services/sandbox-migration.js'; import * as sessionStore from './services/session-store.js'; @@ -15534,11 +15535,13 @@ async function warnGroupJoinScopeOnce(larkAppId: string, detail: string): Promis * prompt is the configured prompt, or empty (the role/identity envelope still * makes it a non-empty CLI turn — the bot reads the group context itself, D8). * - * Scope is mode-aware: a 普通群 gets a chat-scope session anchored at chatId; a - * 话题群 (topic mode) has no thread to attach to yet, so we seed a fresh topic - * (a top-level message) and run a thread-scope session anchored at that seed — - * otherwise a chat-scope session in a 话题群 is the known stale-session bug - * (every reply would wrap into a new topic, and later messages route elsewhere). + * Scope is mode-aware: a 普通群 keeps a chat-scope session anchored at chatId. + * When its reply mode is shared, a top-level seed supplies the visible topic + * root while every turn still reuses that chat-scope session. A 话题群 has no + * thread to attach to yet, so it also seeds a fresh topic, but runs a + * thread-scope session anchored at that seed — otherwise a chat-scope session + * in a 话题群 is the known stale-session bug (every reply would wrap into a new + * topic, and later messages route elsewhere). */ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined, larkAppId: string): Promise { const bot = getBot(larkAppId); @@ -15616,6 +15619,14 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 锚点已有会话,跳过`); return; } + const sharedReplyRootId = mode === 'group' && resolveRegularGroupMode(larkAppId, chatId) === 'shared' + ? await sendMessage( + larkAppId, + chatId, + tr('daemon.auto_start_join_seed', undefined, localeForBot(larkAppId)), + 'text', + ) + : undefined; const { pinnedWorkingDir, pinnedFromBotDefault } = await resolvePinnedWorkingDir({ scope, anchor, chatId, chatType, larkAppId }); const autoWt = willAutoWorktree(larkAppId, pinnedWorkingDir, pinnedFromBotDefault); @@ -15651,7 +15662,12 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined ownerOpenId: operatorOpenId, currentTurnTitle: title, workingDir: pinnedWorkingDir, + pendingTurnId: sharedReplyRootId, }; + if (sharedReplyRootId) { + beginReplyTargetTurn(ds, sharedReplyRootId, sharedReplyRootId, new Date(now).toISOString()); + sessionStore.updateSession(ds.session); + } activeSessions.set(dsKey, ds); // Register the anchor so a later duplicate bot.added for this chat is deduped // even in 话题群 (where dsKey is the seed id, not chatId). @@ -15679,7 +15695,8 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined const prompt = await buildPrompt(); await noteTurnReceived(ds, anchor, promptBody); rememberLastCliInput(ds, promptBody, prompt); - forkWorker(ds, prompt); + forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false); + ds.pendingTurnId = undefined; logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 自动开工(${mode}/${scope}),workingDir=${pinnedWorkingDir}`); return; } @@ -15700,7 +15717,8 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined const prompt = await buildPrompt(); await noteTurnReceived(ds, anchor, promptBody); rememberLastCliInput(ds, promptBody, prompt); - forkWorker(ds, prompt); + forkWorker(ds, prompt, sharedReplyRootId ? { turnId: sharedReplyRootId } : false); + ds.pendingTurnId = undefined; logger.info(`[auto-start:入群] ${chatId.substring(0, 12)} 无默认目录且无可选项目,直接开工`); } } finally { @@ -15708,6 +15726,8 @@ async function handleBotAdded(chatId: string, operatorOpenId: string | undefined } } +export const __testOnly_handleBotAdded = handleBotAdded; + /** Reverse-lookup a foreign bot's display name for a sender open_id observed on * this app's WS events. Priority: * 1) bot-openids-${larkAppId}.json — per-app cross-ref populated by diff --git a/test/group-join-shared-routing.test.ts b/test/group-join-shared-routing.test.ts new file mode 100644 index 000000000..838b90edf --- /dev/null +++ b/test/group-join-shared-routing.test.ts @@ -0,0 +1,276 @@ +/** + * 入群主动开工的会话与飞书展示路由回归测试。 + * + * Run: pnpm vitest run test/group-join-shared-routing.test.ts + */ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + forkWorker: vi.fn(), + getAvailableBots: vi.fn(async () => []), + getChatMode: vi.fn(async () => 'group' as 'group' | 'topic' | 'p2p'), + getProjectScanDirs: vi.fn(() => [] as string[]), + listChatMemberOpenIds: vi.fn(async () => ['ou_owner']), + replyMessage: vi.fn(async () => 'om_reply'), + scanMultipleProjects: vi.fn(() => [] as Array<{ name: string; path: string; type: 'repo' | 'worktree'; branch: string }>), + sendMessage: vi.fn(async () => 'om_join_seed'), +})); + +vi.mock('@larksuiteoapi/node-sdk', () => { + class FakeClient { constructor(public opts: Record) {} } + class FakeWSClient { start() {} } + class FakeEventDispatcher { register() {} } + return { + Client: FakeClient, + WSClient: FakeWSClient, + EventDispatcher: FakeEventDispatcher, + LoggerLevel: { info: 2 }, + }; +}); + +vi.mock('../src/im/lark/client.js', async () => { + const actual = await vi.importActual('../src/im/lark/client.js'); + return { + ...actual, + getChatMode: mocks.getChatMode, + listChatMemberOpenIds: mocks.listChatMemberOpenIds, + replyMessage: mocks.replyMessage, + sendMessage: mocks.sendMessage, + }; +}); + +vi.mock('../src/core/session-manager.js', async () => { + const actual = await vi.importActual('../src/core/session-manager.js'); + return { + ...actual, + ensureSessionWhiteboard: vi.fn(), + getAvailableBots: mocks.getAvailableBots, + getProjectScanDirs: mocks.getProjectScanDirs, + }; +}); + +vi.mock('../src/services/project-scanner.js', async () => { + const actual = await vi.importActual('../src/services/project-scanner.js'); + return { ...actual, scanMultipleProjects: mocks.scanMultipleProjects }; +}); + +vi.mock('../src/core/worker-pool.js', async () => { + const actual = await vi.importActual('../src/core/worker-pool.js'); + return { ...actual, forkWorker: mocks.forkWorker }; +}); + +let tempRoot = ''; +let modules: Awaited>; + +function tempDir(name: string): string { + const dir = join(tempRoot, name); + mkdirSync(dir, { recursive: true }); + return dir; +} + +async function loadModules() { + const registry = await import('../src/bot-registry.js'); + const sessionStore = await import('../src/services/session-store.js'); + const daemon = await import('../src/daemon.js'); + const types = await import('../src/core/types.js'); + sessionStore.init(); + return { daemon, registry, types }; +} + +beforeAll(async () => { + tempRoot = mkdtempSync(join(tmpdir(), 'botmux-group-join-shared-')); + process.env.SESSION_DATA_DIR = tempDir('sessions'); + modules = await loadModules(); +}); + +beforeEach(() => { + modules.registry.__testOnly_resetBotRegistry(); + modules.daemon.__testOnly_activeSessions.clear(); + vi.clearAllMocks(); + mocks.getChatMode.mockResolvedValue('group'); + mocks.getProjectScanDirs.mockReturnValue([]); + mocks.listChatMemberOpenIds.mockResolvedValue(['ou_owner']); + mocks.replyMessage.mockResolvedValue('om_reply'); + mocks.scanMultipleProjects.mockReturnValue([]); + mocks.sendMessage.mockResolvedValue('om_join_seed'); +}); + +afterAll(() => { + delete process.env.SESSION_DATA_DIR; + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('handleBotAdded — 普通群 shared 路由', () => { + it('创建一个话题根并复用 chat-scope session', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_shared'; + const chatId = 'oc_join_shared'; + const seedId = 'om_join_seed'; + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '处理群内未完成请求', + defaultWorkingDir: tempDir('repo-shared'), + regularGroupReplyMode: 'shared', + }); + + await daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + + expect(mocks.sendMessage).toHaveBeenCalledWith( + appId, + chatId, + '🚀 已加入本群,开始工作…', + 'text', + ); + const ds = daemon.__testOnly_activeSessions.get(types.sessionKey(chatId, appId)); + expect(ds).toBeDefined(); + expect(ds?.scope).toBe('chat'); + expect(ds?.session.rootMessageId).toBe(chatId); + expect(ds?.session.currentReplyTarget).toMatchObject({ + rootMessageId: seedId, + turnId: seedId, + }); + expect(ds?.pendingTurnId).toBeUndefined(); + expect(mocks.forkWorker).toHaveBeenCalledWith( + ds, + expect.anything(), + { turnId: seedId }, + ); + + await daemon.__testOnly_sessionReply(chatId, '最终回复', 'text', appId, seedId); + expect(mocks.replyMessage).toHaveBeenCalledWith( + appId, + seedId, + '最终回复', + 'text', + true, + undefined, + expect.anything(), + ); + }); + + it('尊重群级 shared 覆盖而不是只读取 bot 默认值', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_override'; + const chatId = 'oc_join_override'; + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-override'), + regularGroupReplyMode: 'chat', + chatReplyModes: { [chatId]: 'shared' }, + }); + + await daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + + const ds = daemon.__testOnly_activeSessions.get(types.sessionKey(chatId, appId)); + expect(mocks.sendMessage).toHaveBeenCalledTimes(1); + expect(ds?.scope).toBe('chat'); + expect(ds?.session.currentReplyTarget?.rootMessageId).toBe('om_join_seed'); + expect(mocks.forkWorker).toHaveBeenCalledWith( + ds, + expect.anything(), + { turnId: 'om_join_seed' }, + ); + }); + + it('chat 模式保持群顶层平铺且不创建话题根', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_chat'; + const chatId = 'oc_join_chat'; + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-chat'), + regularGroupReplyMode: 'chat', + }); + + await daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + + const ds = daemon.__testOnly_activeSessions.get(types.sessionKey(chatId, appId)); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(ds?.scope).toBe('chat'); + expect(ds?.session.currentReplyTarget).toBeUndefined(); + expect(mocks.forkWorker).toHaveBeenCalledWith(ds, expect.anything(), false); + }); + + it('等待仓库选择时把卡片和延迟首轮留在同一个话题', async () => { + const { daemon, registry, types } = modules; + const appId = 'app_join_pending_repo'; + const chatId = 'oc_join_pending_repo'; + const scanDir = tempDir('scan-pending-repo'); + mocks.getProjectScanDirs.mockReturnValue([scanDir]); + mocks.scanMultipleProjects.mockReturnValue([{ + name: 'botmux', + path: scanDir, + type: 'repo', + branch: 'master', + }]); + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + regularGroupReplyMode: 'shared', + }); + + await daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + + const ds = daemon.__testOnly_activeSessions.get(types.sessionKey(chatId, appId)); + expect(ds?.pendingRepo).toBe(true); + expect(ds?.pendingTurnId).toBe('om_join_seed'); + expect(ds?.repoCardMessageId).toBe('om_reply'); + expect(mocks.forkWorker).not.toHaveBeenCalled(); + expect(mocks.replyMessage).toHaveBeenCalledWith( + appId, + 'om_join_seed', + expect.any(String), + 'interactive', + true, + undefined, + expect.anything(), + ); + }); + + it('话题群继续使用 seed 锚定的 thread-scope session', async () => { + mocks.getChatMode.mockResolvedValue('topic'); + const { daemon, registry, types } = modules; + const appId = 'app_join_topic_group'; + const chatId = 'oc_join_topic_group'; + registry.registerBot({ + larkAppId: appId, + larkAppSecret: 's', + cliId: 'claude-code', + allowedUsers: ['ou_owner'], + autoStartOnGroupJoin: true, + autoStartOnGroupJoinPrompt: '开始排查', + defaultWorkingDir: tempDir('repo-topic-group'), + regularGroupReplyMode: 'shared', + }); + + await daemon.__testOnly_handleBotAdded(chatId, 'ou_owner', appId); + + const ds = daemon.__testOnly_activeSessions.get(types.sessionKey('om_join_seed', appId)); + expect(mocks.sendMessage).toHaveBeenCalledTimes(1); + expect(ds?.scope).toBe('thread'); + expect(ds?.session.rootMessageId).toBe('om_join_seed'); + expect(ds?.session.currentReplyTarget).toBeUndefined(); + expect(mocks.forkWorker).toHaveBeenCalledWith(ds, expect.anything(), false); + }); +});