Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
const bot = getBot(larkAppId);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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;
}
Expand All @@ -15700,14 +15717,17 @@ 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 {
autoStartJoinInFlight.delete(lockKey);
}
}

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
Expand Down
276 changes: 276 additions & 0 deletions test/group-join-shared-routing.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {} }
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<any>('../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<any>('../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<any>('../src/services/project-scanner.js');
return { ...actual, scanMultipleProjects: mocks.scanMultipleProjects };
});

vi.mock('../src/core/worker-pool.js', async () => {
const actual = await vi.importActual<any>('../src/core/worker-pool.js');
return { ...actual, forkWorker: mocks.forkWorker };
});

let tempRoot = '';
let modules: Awaited<ReturnType<typeof loadModules>>;

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);
});
});