From c53471277a00deff9a12aaca228633b9b58fd74c Mon Sep 17 00:00:00 2001 From: xiongz-c <50195037+xiongz-c@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:08:33 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(send):=20=E9=81=BF=E5=85=8D=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E5=BD=93=E5=89=8D=E8=AF=9D=E9=A2=98=E6=9C=BA=E5=99=A8?= =?UTF-8?q?=E4=BA=BA=E7=9A=84=E6=97=A7=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 48 +++++++++++++++++++++---------- src/core/dispatch.ts | 54 ++++++++++++++++++++++++++++++---- test/dispatch.test.ts | 67 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 21 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b1c8fb8c6..ed21b4c01 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,7 +33,7 @@ import { validateWorkingDir } from './core/working-dir.js'; import { resolveSessionContext } from './core/session-marker.js'; import { resolveBotmuxDataDir } from './core/data-dir.js'; import { dashboardSecretPath } from './core/dashboard-secret.js'; -import { acceptedDispatchBotAppIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget } from './core/dispatch.js'; +import { acceptedDispatchBotAppIds, activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget } from './core/dispatch.js'; import { pickTurnReplyTarget } from './core/reply-target.js'; import { recordDispatchRegistryEntry } from './core/dispatch-registry.js'; import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js'; @@ -6779,6 +6779,21 @@ async function cmdSend(rest: string[]): Promise { // topic — defeating the whole point of chat-scope routing. const isChatScope = s.scope === 'chat'; + // Load the sender-scoped bot identity map once. Besides prose @Name + // injection below, it lets the sub-bot hint recognize peers that already + // have an active session in THIS conversation. + let botEntries: BotMentionEntry[] = []; + let crossRef: Record = {}; + try { + const dataDir = resolveDataDir(); + const botInfoPath = join(dataDir, 'bots-info.json'); + botEntries = existsSync(botInfoPath) ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) : []; + const crossRefPath = join(dataDir, `bot-openids-${appId}.json`); + crossRef = existsSync(crossRefPath) + ? JSON.parse(readFileSync(crossRefPath, 'utf-8')) + : {}; + } catch { /* best-effort identity map */ } + // ── Footgun guard: orchestrator → sub-bot ── // A dispatched sub-bot's session lives in its sub-topic; @-ing it from the main // chat spawns a fresh, context-less one. The check is computed ONCE and applied @@ -6791,18 +6806,30 @@ async function cmdSend(rest: string[]): Promise { if (existsSync(regPath)) dispatchReg = JSON.parse(readFileSync(regPath, 'utf-8')); } catch { /* no/!corrupt registry → no guard */ } const dispatchActiveSeeds = new Set(); + let allSessions: SessionData[] = []; if (Object.keys(dispatchReg).length > 0) { - for (const sess of loadSessions().values()) { - if (sess.status === 'active' && sess.scope !== 'chat' && sess.rootMessageId) { + allSessions = [...loadSessions().values()]; + for (const sess of allSessions) { + if (sess.status !== 'active') continue; + if (sess.scope !== 'chat' && sess.rootMessageId) { dispatchActiveSeeds.add(sess.rootMessageId); } } } + const reachableOpenIds = activeConversationBotOpenIds({ + sessions: allSessions, + targetChatId, + currentRootMessageId: s.rootMessageId, + chatScope: isChatScope, + botEntries, + crossRef, + }); // Sub-topic seed if `openId` is a dispatched sub-bot in an active topic that is - // NOT reachable in the current conversation; else null. The bot I'm replying to - // here (quoteTargetSenderOpenId) is reachable, so it's never treated as off-topic. + // NOT reachable in the current conversation; else null. Both the bot I'm + // replying to and any peer with an active session at this conversation anchor + // are reachable, so an unrelated old dispatch topic must not be recommended. const offTopicSubBotSeed = (openId: string): string | null => - offTopicSubBotTopic({ mentionOpenId: openId, quoteTargetSenderOpenId: replyTargetSenderOpenId, chatId: targetChatId, registry: dispatchReg, activeSeeds: dispatchActiveSeeds }); + offTopicSubBotTopic({ mentionOpenId: openId, quoteTargetSenderOpenId: replyTargetSenderOpenId, reachableOpenIds, chatId: targetChatId, registry: dispatchReg, activeSeeds: dispatchActiveSeeds }); // Explicit --mention / --mention-back of an off-topic sub-bot → block + point to // the right command (--anyway overrides). Prose @Name injection is filtered // (dropped, not blocked) at its own site below. @@ -7060,16 +7087,7 @@ async function cmdSend(rest: string[]): Promise { // "获取群组中其他机器人和用户@当前机器人的消息"权限),不再走任何本地 // 转发——botmux 历史上为绕过 Lark 不投递跨 bot 事件搞过 signal-file, // 那套已经在该权限上线后整体下线。 - let botEntries: BotMentionEntry[] = []; - let crossRef: Record = {}; try { - const dataDir = resolveDataDir(); - const botInfoPath = join(dataDir, 'bots-info.json'); - botEntries = existsSync(botInfoPath) ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) : []; - const crossRefPath = join(dataDir, `bot-openids-${appId}.json`); - crossRef = existsSync(crossRefPath) - ? JSON.parse(readFileSync(crossRefPath, 'utf-8')) - : {}; // --no-mention 显式不 @ 任何人:跳过正文 @BotName 的自动注入,否则正文里 // 出现的 @名字 仍会被注入成 ,破坏 --no-mention 语义、还可能误触发对方 // bot(正是要避免的循环 @)。botEntries/crossRef 仍需加载供 footer 寻址用。 diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 0fb1fde29..a4c8e4978 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -224,6 +224,47 @@ export function findSubBotTopic(input: { return null; } +/** + * Resolve sender-scoped open_ids for bots that already have an active session + * at the current conversation anchor. These peers are reachable here, so an + * older dispatch record for the same bot must not be presented as the target. + */ +export function activeConversationBotOpenIds(input: { + sessions: Iterable<{ + status: 'active' | 'closed'; + scope?: 'thread' | 'chat'; + chatId: string; + rootMessageId: string; + larkAppId?: string; + }>; + targetChatId: string; + currentRootMessageId: string; + chatScope: boolean; + botEntries: Array<{ larkAppId: string; botName: string | null }>; + crossRef: Record; +}): Set { + const activeAppIds = new Set(); + for (const session of input.sessions) { + if (session.status !== 'active' + || !session.larkAppId + || session.chatId !== input.targetChatId) continue; + const here = input.chatScope + ? session.scope === 'chat' + : session.scope !== 'chat' + && !!input.currentRootMessageId + && session.rootMessageId === input.currentRootMessageId; + if (here) activeAppIds.add(session.larkAppId); + } + + const openIds = new Set(); + for (const entry of input.botEntries) { + if (!entry.botName || !activeAppIds.has(entry.larkAppId)) continue; + const openId = input.crossRef[entry.botName]; + if (openId) openIds.add(openId); + } + return openIds; +} + /** * Resolve where a `botmux report` should go + who to @, so report-back works * even when the orchestrator is on a DIFFERENT machine. @@ -418,20 +459,21 @@ export function acceptedDispatchBotAppIds(input: { * a dispatched sub-bot in an active topic that is NOT reachable in the current * conversation (so @-ing it here would spawn a context-less session), else null. * - * The bot I'm replying to (`quoteTargetSenderOpenId`) is reachable right here, so - * it's never treated as off-topic — that's the boundary that stops the guard from - * blocking a normal reply to a bot conversing with me. Callers block (explicit - * --mention) or drop (prose injection) on a non-null result, and skip the whole - * check under `--anyway`. + * The bot I'm replying to (`quoteTargetSenderOpenId`) and bots in + * `reachableOpenIds` are reachable right here, so an unrelated older dispatch + * topic is never recommended for them. */ export function offTopicSubBotTopic(input: { mentionOpenId: string; quoteTargetSenderOpenId?: string; + reachableOpenIds?: Set; chatId: string; registry: Record; activeSeeds: Set; }): string | null { - if (!input.mentionOpenId || input.mentionOpenId === input.quoteTargetSenderOpenId) return null; + if (!input.mentionOpenId + || input.mentionOpenId === input.quoteTargetSenderOpenId + || input.reachableOpenIds?.has(input.mentionOpenId)) return null; return findSubBotTopic({ mentionOpenId: input.mentionOpenId, chatId: input.chatId, diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts index a6f7c0ea1..d1bb9bdac 100644 --- a/test/dispatch.test.ts +++ b/test/dispatch.test.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; import { findAncestorSessionContext } from '../src/core/session-marker.js'; import { acceptedDispatchBotAppIds, + activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, @@ -216,6 +217,61 @@ describe('findSubBotTopic', () => { }); }); +describe('activeConversationBotOpenIds', () => { + const botEntries = [ + { larkAppId: 'cli_orchestrator', botName: 'AI Bear' }, + { larkAppId: 'cli_reviewer', botName: 'TraeX' }, + ]; + const crossRef = { + 'AI Bear': 'ou_orchestrator', + 'TraeX': 'ou_reviewer', + }; + + it('finds a peer active in the current topic and excludes the same peer in another topic', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_old', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + currentRootMessageId: 'om_current', + chatScope: false, + botEntries, + crossRef, + }); + expect(result).toEqual(new Set(['ou_reviewer'])); + }); + + it('does not treat a peer active only in another topic as reachable here', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_old', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + currentRootMessageId: 'om_current', + chatScope: false, + botEntries, + crossRef, + }); + expect(result).toEqual(new Set()); + }); + + it('uses the whole chat as the anchor for chat-scope sessions', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_reviewer' }, + { status: 'active', scope: 'chat', chatId: 'oc_else', rootMessageId: 'oc_else', larkAppId: 'cli_orchestrator' }, + ], + targetChatId: 'oc_main', + currentRootMessageId: 'om_irrelevant', + chatScope: true, + botEntries, + crossRef, + }); + expect(result).toEqual(new Set(['ou_reviewer'])); + }); +}); + describe('eligibleAutoMentionAliases', () => { const selfAliases = new Set(['claude', 'claude-code']); const convo = new Set(['cli_reviewer_in_topic']); @@ -255,6 +311,17 @@ describe('offTopicSubBotTopic', () => { expect(offTopicSubBotTopic({ mentionOpenId: 'ou_subbot', quoteTargetSenderOpenId: 'ou_subbot', chatId: 'oc_main', registry, activeSeeds })).toBeNull(); }); + it('does not recommend an old dispatch topic when the bot is already active here', () => { + expect(offTopicSubBotTopic({ + mentionOpenId: 'ou_subbot', + quoteTargetSenderOpenId: 'ou_human', + reachableOpenIds: new Set(['ou_subbot']), + chatId: 'oc_main', + registry, + activeSeeds, + })).toBeNull(); + }); + it('allows a bot that is not a dispatched sub-bot', () => { expect(offTopicSubBotTopic({ mentionOpenId: 'ou_stranger', quoteTargetSenderOpenId: 'ou_human', chatId: 'oc_main', registry, activeSeeds })).toBeNull(); }); From 4d4aec5963ad42a024c83909e85e743d6baedc55 Mon Sep 17 00:00:00 2001 From: xiongz-c <50195037+xiongz-c@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:44:31 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(send):=20=E4=BF=AE=E6=AD=A3=E5=8F=AF?= =?UTF-8?q?=E8=BE=BE=E4=BC=9A=E8=AF=9D=E5=88=A4=E6=96=AD=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 22 ++++++++++----- src/core/dispatch.ts | 29 ++++++++++++++------ test/dispatch.test.ts | 64 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 92 insertions(+), 23 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index ed21b4c01..ec0b3cc11 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6778,6 +6778,10 @@ async function cmdSend(rest: string[]): Promise { // reply_in_thread, otherwise Lark would force every reply into a fresh // topic — defeating the whole point of chat-scope routing. const isChatScope = s.scope === 'chat'; + // Compute the actual outbound anchor before the advisory guard. A chat-scope + // sender can still reply into a per-turn topic, so sender scope alone does + // not describe which peer sessions are reachable. + const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId }); // Load the sender-scoped bot identity map once. Besides prose @Name // injection below, it lets the sub-bot hint recognize peers that already @@ -6787,11 +6791,17 @@ async function cmdSend(rest: string[]): Promise { try { const dataDir = resolveDataDir(); const botInfoPath = join(dataDir, 'bots-info.json'); - botEntries = existsSync(botInfoPath) ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) : []; + const parsedBotEntries = existsSync(botInfoPath) + ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) + : []; + botEntries = Array.isArray(parsedBotEntries) ? parsedBotEntries : []; const crossRefPath = join(dataDir, `bot-openids-${appId}.json`); - crossRef = existsSync(crossRefPath) + const parsedCrossRef = existsSync(crossRefPath) ? JSON.parse(readFileSync(crossRefPath, 'utf-8')) : {}; + crossRef = parsedCrossRef && typeof parsedCrossRef === 'object' && !Array.isArray(parsedCrossRef) + ? parsedCrossRef + : {}; } catch { /* best-effort identity map */ } // ── Footgun guard: orchestrator → sub-bot ── @@ -6819,8 +6829,7 @@ async function cmdSend(rest: string[]): Promise { const reachableOpenIds = activeConversationBotOpenIds({ sessions: allSessions, targetChatId, - currentRootMessageId: s.rootMessageId, - chatScope: isChatScope, + outboundRootMessageId: sendTarget.mode === 'plain' ? undefined : sendTarget.rootMessageId, botEntries, crossRef, }); @@ -6859,9 +6868,8 @@ async function cmdSend(rest: string[]): Promise { rootMessageId: s.rootMessageId, title: s.title, }; - // Dispatch helper: top-level / chat-scope send vs reply-in-thread, single - // decision point. Used for file attachments (always plain in chat scope). - const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId }); + // Dispatch helper below uses the same precomputed target as the advisory + // guard, so the hint and actual Lark delivery cannot disagree about scope. const dispatch = async ( content: string, msgType: string, diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index a4c8e4978..f2915d667 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -238,8 +238,7 @@ export function activeConversationBotOpenIds(input: { larkAppId?: string; }>; targetChatId: string; - currentRootMessageId: string; - chatScope: boolean; + outboundRootMessageId?: string; botEntries: Array<{ larkAppId: string; botName: string | null }>; crossRef: Record; }): Set { @@ -248,18 +247,30 @@ export function activeConversationBotOpenIds(input: { if (session.status !== 'active' || !session.larkAppId || session.chatId !== input.targetChatId) continue; - const here = input.chatScope - ? session.scope === 'chat' - : session.scope !== 'chat' - && !!input.currentRootMessageId - && session.rootMessageId === input.currentRootMessageId; + // A chat-scope peer is reachable from any message in the same group: + // mentions inside a topic fold back into that peer's shared chat session. + // A thread-scope peer is reachable only when this send actually lands in + // the same thread root. + const here = session.scope === 'chat' + || (!!input.outboundRootMessageId + && session.rootMessageId === input.outboundRootMessageId); if (here) activeAppIds.add(session.larkAppId); } const openIds = new Set(); - for (const entry of input.botEntries) { + const entries = Array.isArray(input.botEntries) ? input.botEntries : []; + const crossRef = input.crossRef && typeof input.crossRef === 'object' + ? input.crossRef + : {}; + for (const entry of entries) { if (!entry.botName || !activeAppIds.has(entry.larkAppId)) continue; - const openId = input.crossRef[entry.botName]; + // crossRef is keyed by display name. When several apps share that name, + // the value cannot prove which app it represents; fail closed and retain + // the old-topic hint instead of silencing it for the wrong bot. + const sameNameEntries = entries.filter(candidate => + candidate.botName?.toLowerCase() === entry.botName!.toLowerCase()); + if (sameNameEntries.length !== 1) continue; + const openId = crossRef[entry.botName]; if (openId) openIds.add(openId); } return openIds; diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts index d1bb9bdac..eed282951 100644 --- a/test/dispatch.test.ts +++ b/test/dispatch.test.ts @@ -234,8 +234,7 @@ describe('activeConversationBotOpenIds', () => { { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_old', larkAppId: 'cli_reviewer' }, ], targetChatId: 'oc_main', - currentRootMessageId: 'om_current', - chatScope: false, + outboundRootMessageId: 'om_current', botEntries, crossRef, }); @@ -248,28 +247,79 @@ describe('activeConversationBotOpenIds', () => { { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_old', larkAppId: 'cli_reviewer' }, ], targetChatId: 'oc_main', - currentRootMessageId: 'om_current', - chatScope: false, + outboundRootMessageId: 'om_current', botEntries, crossRef, }); expect(result).toEqual(new Set()); }); - it('uses the whole chat as the anchor for chat-scope sessions', () => { + it('treats a same-chat chat-scope peer as reachable from a thread sender', () => { const result = activeConversationBotOpenIds({ sessions: [ { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_reviewer' }, { status: 'active', scope: 'chat', chatId: 'oc_else', rootMessageId: 'oc_else', larkAppId: 'cli_orchestrator' }, ], targetChatId: 'oc_main', - currentRootMessageId: 'om_irrelevant', - chatScope: true, + outboundRootMessageId: 'om_current', botEntries, crossRef, }); expect(result).toEqual(new Set(['ou_reviewer'])); }); + + it('treats a same-root thread peer as reachable from a chat-scope reply into that topic', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries, + crossRef, + }); + expect(result).toEqual(new Set(['ou_reviewer'])); + }); + + it('does not treat a thread peer as reachable from a plain chat send', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + botEntries, + crossRef, + }); + expect(result).toEqual(new Set()); + }); + + it('fails closed when multiple bot apps share the same display name', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_same_1' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries: [ + { larkAppId: 'cli_same_1', botName: 'Same' }, + { larkAppId: 'cli_same_2', botName: 'Same' }, + ], + crossRef: { Same: 'ou_same_2' }, + }); + expect(result).toEqual(new Set()); + }); + + it('treats a non-array bot identity payload as empty instead of throwing', () => { + expect(activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries: {} as any, + crossRef, + })).toEqual(new Set()); + }); }); describe('eligibleAutoMentionAliases', () => { From 70024ff9545863891aec6a8e06214db6c8baa053 Mon Sep 17 00:00:00 2001 From: xiongz-c <50195037+xiongz-c@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:15:33 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(send):=20=E6=94=B6=E7=B4=A7=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8F=AF=E8=BE=BE=E6=80=A7=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 28 +++++++++-- src/core/dispatch.ts | 69 ++++++++++++++++++++++----- test/dispatch.test.ts | 106 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 15 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index ec0b3cc11..0c3e9270f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -33,7 +33,7 @@ import { validateWorkingDir } from './core/working-dir.js'; import { resolveSessionContext } from './core/session-marker.js'; import { resolveBotmuxDataDir } from './core/data-dir.js'; import { dashboardSecretPath } from './core/dashboard-secret.js'; -import { acceptedDispatchBotAppIds, activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget } from './core/dispatch.js'; +import { acceptedDispatchBotAppIds, activeConversationBotOpenIds, appendDispatchReportProtocol, appendLegacyDispatchReportProtocol, parseDispatchBotSpec, buildDispatchMessages, buildRepoPrimeText, buildReportContent, eligibleAutoMentionAliases, findDispatchRegistryEntry, foldableChatSessionAppIds, offTopicSubBotTopic, resolveReportTarget, resolveSendTarget, threadRootForReachability } from './core/dispatch.js'; import { pickTurnReplyTarget } from './core/reply-target.js'; import { recordDispatchRegistryEntry } from './core/dispatch-registry.js'; import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js'; @@ -6766,7 +6766,8 @@ async function cmdSend(rest: string[]): Promise { } // Register bots so Lark client works - const { registerBot, loadBotConfigs, findOncallChatForAnyBot } = await import('./bot-registry.js'); + const { registerBot, loadBotConfigs, findOncallChatForAnyBot, getBot } = await import('./bot-registry.js'); + const { resolveRegularGroupMode } = await import('./services/chat-reply-mode-store.js'); try { for (const cfg of loadBotConfigs()) registerBot(cfg); } catch { /* */ } if (envPinnedRiffBot) { try { registerBot(envPinnedRiffBot); } catch { /* */ } } @@ -6794,7 +6795,13 @@ async function cmdSend(rest: string[]): Promise { const parsedBotEntries = existsSync(botInfoPath) ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) : []; - botEntries = Array.isArray(parsedBotEntries) ? parsedBotEntries : []; + botEntries = Array.isArray(parsedBotEntries) + ? parsedBotEntries.filter((entry): entry is BotMentionEntry => + !!entry + && typeof entry === 'object' + && typeof entry.larkAppId === 'string' + && (entry.botName === null || typeof entry.botName === 'string')) + : []; const crossRefPath = join(dataDir, `bot-openids-${appId}.json`); const parsedCrossRef = existsSync(crossRefPath) ? JSON.parse(readFileSync(crossRefPath, 'utf-8')) @@ -6826,10 +6833,23 @@ async function cmdSend(rest: string[]): Promise { } } } + // An active chat-scope session can outlive a /reply-mode switch. Verify the + // target bot's current effective mode before assuming mentions still fold + // back into that old session. + const foldableChatAppIds = foldableChatSessionAppIds({ + sessions: allSessions, + targetChatId, + outboundMode: sendTarget.mode, + resolveMode: (larkAppId, chatId) => { + getBot(larkAppId); // unknown target bot must fail closed + return resolveRegularGroupMode(larkAppId, chatId); + }, + }); const reachableOpenIds = activeConversationBotOpenIds({ sessions: allSessions, targetChatId, - outboundRootMessageId: sendTarget.mode === 'plain' ? undefined : sendTarget.rootMessageId, + outboundRootMessageId: threadRootForReachability(sendTarget), + foldableChatAppIds, botEntries, crossRef, }); diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index f2915d667..0e2850e72 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -14,6 +14,7 @@ * (cli.ts) performs the actual sendMessage + replyMessage. */ +import type { SessionReplyTarget } from './reply-target.js'; export { resolveSendTarget } from './reply-target.js'; export interface DispatchBot { @@ -224,21 +225,60 @@ export function findSubBotTopic(input: { return null; } +/** A quote reply references a root but does not enter that root's thread. */ +export function threadRootForReachability(target: SessionReplyTarget): string | undefined { + return target.mode === 'thread' ? target.rootMessageId : undefined; +} + +type ReachabilitySession = { + status: 'active' | 'closed'; + scope?: 'thread' | 'chat'; + chatId: string; + rootMessageId: string; + larkAppId?: string; +}; + +/** + * Identify active chat sessions whose bot is still configured to fold a + * mention back into that shared session. Mode lookup failures fail closed. + */ +export function foldableChatSessionAppIds(input: { + sessions: Iterable; + targetChatId: string; + outboundMode: SessionReplyTarget['mode']; + resolveMode: (larkAppId: string, chatId: string) => 'chat' | 'shared' | 'new-topic' | 'chat-topic' | undefined; +}): Set { + const appIds = new Set(); + for (const session of input.sessions) { + if (session.status !== 'active' + || session.scope !== 'chat' + || !session.larkAppId + || session.chatId !== input.targetChatId) continue; + try { + const mode = input.resolveMode(session.larkAppId, input.targetChatId); + // chat-topic reuses the chat session for top-level/quote delivery, but + // deliberately keeps a real Lark topic isolated. new-topic never folds + // a new mention back into a leftover chat session. + if (mode === 'chat' + || mode === 'shared' + || (mode === 'chat-topic' && input.outboundMode !== 'thread')) { + appIds.add(session.larkAppId); + } + } catch { /* unknown bot/mode → retain advisory */ } + } + return appIds; +} + /** * Resolve sender-scoped open_ids for bots that already have an active session * at the current conversation anchor. These peers are reachable here, so an * older dispatch record for the same bot must not be presented as the target. */ export function activeConversationBotOpenIds(input: { - sessions: Iterable<{ - status: 'active' | 'closed'; - scope?: 'thread' | 'chat'; - chatId: string; - rootMessageId: string; - larkAppId?: string; - }>; + sessions: Iterable; targetChatId: string; outboundRootMessageId?: string; + foldableChatAppIds?: Set; botEntries: Array<{ larkAppId: string; botName: string | null }>; crossRef: Record; }): Set { @@ -252,13 +292,20 @@ export function activeConversationBotOpenIds(input: { // A thread-scope peer is reachable only when this send actually lands in // the same thread root. const here = session.scope === 'chat' - || (!!input.outboundRootMessageId - && session.rootMessageId === input.outboundRootMessageId); + ? input.foldableChatAppIds?.has(session.larkAppId) === true + : !!input.outboundRootMessageId + && session.rootMessageId === input.outboundRootMessageId; if (here) activeAppIds.add(session.larkAppId); } const openIds = new Set(); - const entries = Array.isArray(input.botEntries) ? input.botEntries : []; + const entries = Array.isArray(input.botEntries) + ? input.botEntries.filter((entry): entry is { larkAppId: string; botName: string | null } => + !!entry + && typeof entry === 'object' + && typeof entry.larkAppId === 'string' + && (entry.botName === null || typeof entry.botName === 'string')) + : []; const crossRef = input.crossRef && typeof input.crossRef === 'object' ? input.crossRef : {}; @@ -271,7 +318,7 @@ export function activeConversationBotOpenIds(input: { candidate.botName?.toLowerCase() === entry.botName!.toLowerCase()); if (sameNameEntries.length !== 1) continue; const openId = crossRef[entry.botName]; - if (openId) openIds.add(openId); + if (typeof openId === 'string' && openId) openIds.add(openId); } return openIds; } diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts index eed282951..0a35f4401 100644 --- a/test/dispatch.test.ts +++ b/test/dispatch.test.ts @@ -27,9 +27,11 @@ import { findSubBotTopic, eligibleAutoMentionAliases, offTopicSubBotTopic, + foldableChatSessionAppIds, recordDispatchInputCommit, resolveReportTarget, resolveSendTarget, + threadRootForReachability, } from '../src/core/dispatch.js'; describe('parseDispatchBotSpec', () => { @@ -241,6 +243,20 @@ describe('activeConversationBotOpenIds', () => { expect(result).toEqual(new Set(['ou_reviewer'])); }); + it('does not trust a leftover chat session after the bot stops using a foldable mode', () => { + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + foldableChatAppIds: new Set(), + botEntries, + crossRef, + }); + expect(result).toEqual(new Set()); + }); + it('does not treat a peer active only in another topic as reachable here', () => { const result = activeConversationBotOpenIds({ sessions: [ @@ -262,6 +278,7 @@ describe('activeConversationBotOpenIds', () => { ], targetChatId: 'oc_main', outboundRootMessageId: 'om_current', + foldableChatAppIds: new Set(['cli_reviewer']), botEntries, crossRef, }); @@ -293,6 +310,20 @@ describe('activeConversationBotOpenIds', () => { expect(result).toEqual(new Set()); }); + it('does not treat a quoted root as entering a thread peer session', () => { + const quoteTarget = { mode: 'quote' as const, rootMessageId: 'om_current' }; + const result = activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: threadRootForReachability(quoteTarget), + botEntries, + crossRef, + }); + expect(result).toEqual(new Set()); + }); + it('fails closed when multiple bot apps share the same display name', () => { const result = activeConversationBotOpenIds({ sessions: [ @@ -320,6 +351,81 @@ describe('activeConversationBotOpenIds', () => { crossRef, })).toEqual(new Set()); }); + + it('ignores malformed elements inside a bot identity array', () => { + expect(activeConversationBotOpenIds({ + sessions: [ + { status: 'active', scope: 'thread', chatId: 'oc_main', rootMessageId: 'om_current', larkAppId: 'cli_reviewer' }, + ], + targetChatId: 'oc_main', + outboundRootMessageId: 'om_current', + botEntries: [null, 1, {}, ...botEntries] as any, + crossRef, + })).toEqual(new Set(['ou_reviewer'])); + }); +}); + +describe('send-target reachability helpers', () => { + it('only exposes a root for a real thread send, not quote/plain delivery', () => { + expect(threadRootForReachability({ mode: 'thread', rootMessageId: 'om_thread' })).toBe('om_thread'); + expect(threadRootForReachability({ mode: 'quote', rootMessageId: 'om_quote' })).toBeUndefined(); + expect(threadRootForReachability({ mode: 'plain', chatId: 'oc_chat' })).toBeUndefined(); + }); + + it('accepts only currently foldable chat sessions in the target chat', () => { + const sessions = [ + { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_chat' }, + { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_new_topic' }, + { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_else', rootMessageId: 'oc_else', larkAppId: 'cli_elsewhere' }, + ]; + const modes = new Map([ + ['cli_chat', 'chat' as const], + ['cli_new_topic', 'new-topic' as const], + ['cli_elsewhere', 'shared' as const], + ]); + expect(foldableChatSessionAppIds({ + sessions, + targetChatId: 'oc_main', + outboundMode: 'thread', + resolveMode: appId => modes.get(appId), + })).toEqual(new Set(['cli_chat'])); + }); + + it('keeps chat-topic chat sessions reachable only for top-level-like delivery', () => { + const sessions = [ + { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_chat_topic' }, + ]; + const resolveMode = () => 'chat-topic' as const; + expect(foldableChatSessionAppIds({ + sessions, + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode, + })).toEqual(new Set(['cli_chat_topic'])); + expect(foldableChatSessionAppIds({ + sessions, + targetChatId: 'oc_main', + outboundMode: 'quote', + resolveMode, + })).toEqual(new Set(['cli_chat_topic'])); + expect(foldableChatSessionAppIds({ + sessions, + targetChatId: 'oc_main', + outboundMode: 'thread', + resolveMode, + })).toEqual(new Set()); + }); + + it('fails closed when the target bot reply mode cannot be resolved', () => { + expect(foldableChatSessionAppIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_unknown' }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => { throw new Error('unknown bot'); }, + })).toEqual(new Set()); + }); }); describe('eligibleAutoMentionAliases', () => { From 13fcd78da2fe046f1d173ce91d6dd2ad8b0a2ea5 Mon Sep 17 00:00:00 2001 From: xiongz-c <50195037+xiongz-c@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:28:08 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(send):=20=E5=AF=B9=E9=BD=90=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E4=BC=9A=E8=AF=9D=E7=9A=84=E5=AE=9E=E9=99=85=E5=8F=91?= =?UTF-8?q?=E9=80=81=E8=90=BD=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli.ts | 33 ++++++++++----- src/cli/deferred-topic-send.ts | 35 +++++++++++++--- src/core/dispatch.ts | 33 ++++++++++++--- test/deferred-topic-send.test.ts | 14 ++++++- test/dispatch.test.ts | 69 ++++++++++++++++++++++++++++---- 5 files changed, 154 insertions(+), 30 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 0c3e9270f..1a43cffd9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -83,7 +83,8 @@ import { expandHomePath, invalidWorkingDirs } from './utils/working-dir.js'; import { firstPositional } from './cli/arg-utils.js'; import { isColdResumeDormant, sessionListDisposition } from './cli/session-list-liveness.js'; import { dispatchPrimaryMessage, findStdinAliasAttachment, normalizeInteractiveCardInput, sendFileAttachments, sendVideoAttachments, shouldSendAsPureVideo, validateVideoAttachments } from './cli/send-dispatch.js'; -import { dispatchDeferredTopicSend, type DeferredScheduleRunData } from './cli/deferred-topic-send.js'; +import { dispatchDeferredTopicSend, reusableDeferredTopicRoot, type DeferredScheduleRunData } from './cli/deferred-topic-send.js'; +import { readDeferredTopicBinding } from './core/deferred-topic-binding.js'; import { resolveDaemonEnv } from './cli/daemon-lifecycle-env.js'; import { buildPm2SpawnCommand } from './cli/pm2-command.js'; import { callDashboard, type DashboardEndpoint, type DashboardResult } from './cli/dashboard-endpoint.js'; @@ -6771,7 +6772,7 @@ async function cmdSend(rest: string[]): Promise { try { for (const cfg of loadBotConfigs()) registerBot(cfg); } catch { /* */ } if (envPinnedRiffBot) { try { registerBot(envPinnedRiffBot); } catch { /* */ } } - const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError } = await import('./im/lark/client.js'); + const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError, getChatModeStrict } = await import('./im/lark/client.js'); const appId = s.larkAppId!; // Effective target chat for top-level mode (defaults to session's chat) const targetChatId = overrideChatId ?? s.chatId; @@ -6783,6 +6784,19 @@ async function cmdSend(rest: string[]): Promise { // sender can still reply into a per-turn topic, so sender scope alone does // not describe which peer sessions are reachable. const sendTarget = resolveSendTarget({ into: sendInto, topLevel: sendTopLevel, chatScope: isChatScope, chatId: targetChatId, rootMessageId: s.rootMessageId, replyTargetRootId: turnReplyTarget?.rootMessageId, replyTargetTurnId: turnReplyTarget?.turnId, replyTargetQuoteOnly: turnReplyTarget?.quoteOnly, currentTurnId }); + const dataDir = resolveDataDir(); + const deferredBinding = !sendInto && (!overrideChatId || overrideChatId === s.chatId) + ? readDeferredTopicBinding(dataDir, s.sessionId) + : undefined; + const deferredRoot = reusableDeferredTopicRoot({ + session: s as SessionData & { larkAppId: string }, + binding: deferredBinding, + explicitTopLevel: sendTopLevel, + reuseBoundRootWhenTopLevel: deferredMaterializedByThisCommand, + }); + const reachabilityTarget = deferredRoot + ? { mode: 'thread' as const, rootMessageId: deferredRoot } + : sendTarget; // Load the sender-scoped bot identity map once. Besides prose @Name // injection below, it lets the sub-bot hint recognize peers that already @@ -6790,7 +6804,6 @@ async function cmdSend(rest: string[]): Promise { let botEntries: BotMentionEntry[] = []; let crossRef: Record = {}; try { - const dataDir = resolveDataDir(); const botInfoPath = join(dataDir, 'bots-info.json'); const parsedBotEntries = existsSync(botInfoPath) ? JSON.parse(readFileSync(botInfoPath, 'utf-8')) @@ -6819,7 +6832,7 @@ async function cmdSend(rest: string[]): Promise { // `@OtherSubBot` can't slip past after this explicit guard already ran. let dispatchReg: Record = {}; try { - const regPath = join(resolveDataDir(), 'orchestrate-dispatch.json'); + const regPath = join(dataDir, 'orchestrate-dispatch.json'); if (existsSync(regPath)) dispatchReg = JSON.parse(readFileSync(regPath, 'utf-8')); } catch { /* no/!corrupt registry → no guard */ } const dispatchActiveSeeds = new Set(); @@ -6836,19 +6849,20 @@ async function cmdSend(rest: string[]): Promise { // An active chat-scope session can outlive a /reply-mode switch. Verify the // target bot's current effective mode before assuming mentions still fold // back into that old session. - const foldableChatAppIds = foldableChatSessionAppIds({ + const foldableChatAppIds = await foldableChatSessionAppIds({ sessions: allSessions, targetChatId, - outboundMode: sendTarget.mode, + outboundMode: reachabilityTarget.mode, resolveMode: (larkAppId, chatId) => { getBot(larkAppId); // unknown target bot must fail closed return resolveRegularGroupMode(larkAppId, chatId); }, + resolveChatMode: chatId => getChatModeStrict(appId, chatId), }); const reachableOpenIds = activeConversationBotOpenIds({ sessions: allSessions, targetChatId, - outboundRootMessageId: threadRootForReachability(sendTarget), + outboundRootMessageId: threadRootForReachability(reachabilityTarget), foldableChatAppIds, botEntries, crossRef, @@ -6888,8 +6902,9 @@ async function cmdSend(rest: string[]): Promise { rootMessageId: s.rootMessageId, title: s.title, }; - // Dispatch helper below uses the same precomputed target as the advisory - // guard, so the hint and actual Lark delivery cannot disagree about scope. + // Ordinary delivery uses the nominal target. Deferred delivery gets first + // refusal below; the advisory mirrors its existing binding in + // `reachabilityTarget` above so both paths agree about the effective root. const dispatch = async ( content: string, msgType: string, diff --git a/src/cli/deferred-topic-send.ts b/src/cli/deferred-topic-send.ts index b20754aad..0b63c5e76 100644 --- a/src/cli/deferred-topic-send.ts +++ b/src/cli/deferred-topic-send.ts @@ -27,6 +27,34 @@ export interface DeferredTopicSendResult { materializedNow?: boolean; } +export function deferredTopicBindingMatches( + session: DeferredTopicSendSession, + binding: DeferredTopicBinding, +): boolean { + const run = session.deferredScheduleRun; + return !!run + && binding.sessionId === session.sessionId + && binding.turnId === run.turnId + && binding.chatId === session.chatId + && binding.larkAppId === session.larkAppId + && binding.routingAnchor === run.routingAnchor; +} + +/** Existing deferred runs reply into their materialized topic before the + * nominal send target is considered. Mirror that precedence for advisory + * reachability without materializing a new topic. */ +export function reusableDeferredTopicRoot(input: { + session: DeferredTopicSendSession; + binding: DeferredTopicBinding | undefined; + explicitTopLevel: boolean; + reuseBoundRootWhenTopLevel?: boolean; +}): string | undefined { + const { binding } = input; + if (!binding || !deferredTopicBindingMatches(input.session, binding)) return undefined; + if (input.explicitTopLevel && !input.reuseBoundRootWhenTopLevel) return undefined; + return binding.rootMessageId; +} + /** Route one `botmux send` effect for a lazily-materialized schedule topic. * The per-session file lock spans the provider request so two concurrent CLI * processes cannot both create a root. A stable provider UUID (sessionId) @@ -50,12 +78,7 @@ export async function dispatchDeferredTopicSend(opts: { return withDeferredTopicBindingLock(opts.dataDir, opts.session.sessionId, async () => { const existing = readDeferredTopicBinding(opts.dataDir, opts.session.sessionId); if (existing) { - if ( - existing.turnId !== run.turnId - || existing.chatId !== opts.session.chatId - || existing.larkAppId !== opts.session.larkAppId - || existing.routingAnchor !== run.routingAnchor - ) { + if (!deferredTopicBindingMatches(opts.session, existing)) { throw new Error('deferred topic binding identity mismatch'); } if (opts.explicitTopLevel && !opts.reuseBoundRootWhenTopLevel) return { handled: false }; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 0e2850e72..0f1ce96e9 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -236,33 +236,54 @@ type ReachabilitySession = { chatId: string; rootMessageId: string; larkAppId?: string; + deferredScheduleRun?: unknown; + vcMeetingReceiver?: unknown; }; /** * Identify active chat sessions whose bot is still configured to fold a * mention back into that shared session. Mode lookup failures fail closed. */ -export function foldableChatSessionAppIds(input: { +export async function foldableChatSessionAppIds(input: { sessions: Iterable; targetChatId: string; outboundMode: SessionReplyTarget['mode']; resolveMode: (larkAppId: string, chatId: string) => 'chat' | 'shared' | 'new-topic' | 'chat-topic' | undefined; -}): Set { - const appIds = new Set(); + resolveChatMode: (chatId: string) => Promise<'group' | 'topic' | 'p2p' | 'unknown' | undefined>; +}): Promise> { + const candidates = new Set(); for (const session of input.sessions) { if (session.status !== 'active' || session.scope !== 'chat' || !session.larkAppId - || session.chatId !== input.targetChatId) continue; + || session.chatId !== input.targetChatId + // These chat-scoped sessions deliberately use isolated routing keys and + // can never be reached through the ordinary (chatId, appId) slot. + || session.deferredScheduleRun + || session.vcMeetingReceiver) continue; + candidates.add(session.larkAppId); + } + + const appIds = new Set(); + if (candidates.size === 0) return appIds; + // Topology belongs to the chat, not to an individual bot. Resolve it once + // through the sending bot's authenticated client. + let chatMode: 'group' | 'topic' | 'p2p' | 'unknown' | undefined; + try { + chatMode = await input.resolveChatMode(input.targetChatId); + } catch { /* lookup failure → retain advisory */ } + if (chatMode !== 'group') return appIds; + + for (const larkAppId of candidates) { try { - const mode = input.resolveMode(session.larkAppId, input.targetChatId); + const mode = input.resolveMode(larkAppId, input.targetChatId); // chat-topic reuses the chat session for top-level/quote delivery, but // deliberately keeps a real Lark topic isolated. new-topic never folds // a new mention back into a leftover chat session. if (mode === 'chat' || mode === 'shared' || (mode === 'chat-topic' && input.outboundMode !== 'thread')) { - appIds.add(session.larkAppId); + appIds.add(larkAppId); } } catch { /* unknown bot/mode → retain advisory */ } } diff --git a/test/deferred-topic-send.test.ts b/test/deferred-topic-send.test.ts index 31b074963..9c3e001f2 100644 --- a/test/deferred-topic-send.test.ts +++ b/test/deferred-topic-send.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { dispatchDeferredTopicSend } from '../src/cli/deferred-topic-send.js'; +import { dispatchDeferredTopicSend, reusableDeferredTopicRoot } from '../src/cli/deferred-topic-send.js'; import { deferredTopicBindingPath, readDeferredTopicBinding, @@ -92,6 +92,13 @@ describe('deferred fresh-topic botmux send routing', () => { it('reuses the materialized topic for later turns while explicit top-level remains an escape hatch', async () => { await dispatchDeferredTopicSend(makeOptions()); + const binding = readDeferredTopicBinding(dataDir, SESSION_ID)!; + expect(reusableDeferredTopicRoot({ + session: makeOptions().session, + binding, + explicitTopLevel: false, + })).toBe('om_alert_root'); + const followUp = makeOptions({ currentTurnId: 'human-turn-2', content: 'more detail' }); const result = await dispatchDeferredTopicSend(followUp); @@ -100,6 +107,11 @@ describe('deferred fresh-topic botmux send routing', () => { expect(result).toMatchObject({ handled: true, rootMessageId: 'om_alert_root', materializedNow: false }); const topLevel = makeOptions({ currentTurnId: 'human-turn-3', explicitTopLevel: true }); + expect(reusableDeferredTopicRoot({ + session: topLevel.session, + binding, + explicitTopLevel: true, + })).toBeUndefined(); expect(await dispatchDeferredTopicSend(topLevel)).toEqual({ handled: false }); expect(topLevel.replyRoot).not.toHaveBeenCalled(); }); diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts index 0a35f4401..f5af8cc69 100644 --- a/test/dispatch.test.ts +++ b/test/dispatch.test.ts @@ -372,7 +372,7 @@ describe('send-target reachability helpers', () => { expect(threadRootForReachability({ mode: 'plain', chatId: 'oc_chat' })).toBeUndefined(); }); - it('accepts only currently foldable chat sessions in the target chat', () => { + it('accepts only currently foldable ordinary-group chat sessions in the target chat', async () => { const sessions = [ { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_chat' }, { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_new_topic' }, @@ -383,47 +383,100 @@ describe('send-target reachability helpers', () => { ['cli_new_topic', 'new-topic' as const], ['cli_elsewhere', 'shared' as const], ]); - expect(foldableChatSessionAppIds({ + expect(await foldableChatSessionAppIds({ sessions, targetChatId: 'oc_main', outboundMode: 'thread', resolveMode: appId => modes.get(appId), + resolveChatMode: async () => 'group', })).toEqual(new Set(['cli_chat'])); }); - it('keeps chat-topic chat sessions reachable only for top-level-like delivery', () => { + it('keeps chat-topic chat sessions reachable only for top-level-like delivery', async () => { const sessions = [ { status: 'active' as const, scope: 'chat' as const, chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_chat_topic' }, ]; const resolveMode = () => 'chat-topic' as const; - expect(foldableChatSessionAppIds({ + expect(await foldableChatSessionAppIds({ sessions, targetChatId: 'oc_main', outboundMode: 'plain', resolveMode, + resolveChatMode: async () => 'group', })).toEqual(new Set(['cli_chat_topic'])); - expect(foldableChatSessionAppIds({ + expect(await foldableChatSessionAppIds({ sessions, targetChatId: 'oc_main', outboundMode: 'quote', resolveMode, + resolveChatMode: async () => 'group', })).toEqual(new Set(['cli_chat_topic'])); - expect(foldableChatSessionAppIds({ + expect(await foldableChatSessionAppIds({ sessions, targetChatId: 'oc_main', outboundMode: 'thread', resolveMode, + resolveChatMode: async () => 'group', })).toEqual(new Set()); }); - it('fails closed when the target bot reply mode cannot be resolved', () => { - expect(foldableChatSessionAppIds({ + it('excludes isolated deferred and VC chat sessions from the ordinary routing slot', async () => { + expect(await foldableChatSessionAppIds({ + sessions: [ + { + status: 'active', + scope: 'chat', + chatId: 'oc_main', + rootMessageId: 'om_deferred', + larkAppId: 'cli_deferred', + deferredScheduleRun: { routingAnchor: 'schedule-run:1' }, + }, + { + status: 'active', + scope: 'chat', + chatId: 'oc_main', + rootMessageId: 'om_vc', + larkAppId: 'cli_vc', + vcMeetingReceiver: { meetingId: 'meeting-1' }, + }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => 'chat', + resolveChatMode: async () => 'group', + })).toEqual(new Set()); + }); + + it('fails closed after a regular group becomes a topic chat', async () => { + expect(await foldableChatSessionAppIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_stale' }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => 'chat', + resolveChatMode: async () => 'topic', + })).toEqual(new Set()); + }); + + it('fails closed when the target bot reply mode or chat topology cannot be resolved', async () => { + expect(await foldableChatSessionAppIds({ sessions: [ { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_unknown' }, ], targetChatId: 'oc_main', outboundMode: 'plain', resolveMode: () => { throw new Error('unknown bot'); }, + resolveChatMode: async () => 'group', + })).toEqual(new Set()); + expect(await foldableChatSessionAppIds({ + sessions: [ + { status: 'active', scope: 'chat', chatId: 'oc_main', rootMessageId: 'oc_main', larkAppId: 'cli_unknown' }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => 'chat', + resolveChatMode: async () => 'unknown', })).toEqual(new Set()); }); });