diff --git a/src/cli.ts b/src/cli.ts index 48cc64e3b..6b5d44aff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3284,6 +3284,13 @@ interface SessionData { currentReplyTarget?: { rootMessageId: string; turnId: string; updatedAt: string; quoteOnly?: boolean; substitute?: boolean }; /** Per-turn reply targets(见 Session.replyTargets in types.ts)——排队/并发轮次各自的回复锚点。 */ replyTargets?: Record; + /** Frozen per-turn reply contexts(见 Session.turnReplyContexts in types.ts)。 + * `botmux send` 只读其中的 `inThread`:判断本轮 quote 目标当初是否从**顶层** + * 进来,据此拦住「顶层 @ 之后那条消息才被开成话题」时 quote 把回复带进话题。 */ + turnReplyContexts?: Record; codexAppDispatchLedger?: CodexAppDispatchLedgerEntry[]; codexAppGenerationCommits?: unknown; queued?: boolean; @@ -7018,6 +7025,7 @@ import { config } from './config.js'; import { getSessionUsageSnapshot } from './core/cost-calculator.js'; import { resolveQuoteTarget, + shouldDropAfterTheFactTopicQuote, validateMentionDecision, mentionBackAmbiguity, mentionBackAmbiguityError, @@ -8534,7 +8542,7 @@ async function cmdSend(rest: string[]): Promise { if (!statSync(p).isFile()) { console.error(`不是普通文件: ${p}`); process.exit(1); } } - const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError, getChatModeStrict } = await import('./im/lark/client.js'); + const { sendMessage, replyMessage, uploadImage, uploadFile, MessageWithdrawnError, getChatModeStrict, getMessageThreadId } = 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; @@ -8790,6 +8798,36 @@ async function cmdSend(rest: string[]): Promise { ?? frozenTurnDispatch?.quoteTargetId ?? s.quoteTargetId, }); + // 「顶层 @ 之后那条消息才被开成话题」的发送侧半边。飞书的 reply 接口让回复继承 + // 被引用消息**此刻**的话题归属(`reply_in_thread:false` 只是不新开话题,逃不出 + // 已有话题),所以引用一条事后被开成话题的顶层消息,会把回复带进用户根本没在 + // 其中 @ 过 bot 的话题里 —— dispatcher 侧的 fold 只管住了卡片,正文由这里决定。 + // + // 只在「该轮确证从顶层进来(inThread === false)且本次真要 quote」时才探测一次 + // 飞书;话题内轮次、`--top-level`、`--no-quote`、`--quote`、thread-scope 全部在 + // 上面或 `shouldDropAfterTheFactTopicQuote` 里短路,普通热路径不多付这次调用。 + // 探测失败一律保持 quote(既有默认行为),绝不因为不确定就改变所有正常回复的落点。 + const quotedTurnInThread = quoteTargetId + ? (s.turnReplyContexts?.[currentTurnId ?? '']?.inThread + ?? s.turnReplyContexts?.[quoteTargetId]?.inThread) + : undefined; + let effectiveQuoteTargetId = quoteTargetId; + if (quoteTargetId && !explicitQuote && quotedTurnInThread === false) { + const probedThreadId = await getMessageThreadId(appId, quoteTargetId).catch(() => undefined); + if (shouldDropAfterTheFactTopicQuote({ + quoteTargetId, + quotedTurnInThread, + currentThreadId: probedThreadId, + explicitQuote, + })) { + logger.info( + `[send] quote target ${quoteTargetId.substring(0, 12)} was answered flat at top level but now ` + + `belongs to topic ${String(probedThreadId).substring(0, 12)}; posting flat instead of quoting ` + + 'so the reply does not land in an after-the-fact topic', + ); + effectiveQuoteTargetId = undefined; + } + } let primaryQuotedId: string | null = null; let vcMeetingListenerReplyReplay = false; const dispatchPrimary = async ( @@ -8802,7 +8840,7 @@ async function cmdSend(rest: string[]): Promise { revalidateVcMeetingManagedSend(); const proposedOutput = { targetChatId, - ...(quoteTargetId ? { quoteTargetId } : {}), + ...(effectiveQuoteTargetId ? { quoteTargetId: effectiveQuoteTargetId } : {}), msgType, content, }; diff --git a/src/core/reply-target.ts b/src/core/reply-target.ts index 4eeca70d1..11ca7fe13 100644 --- a/src/core/reply-target.ts +++ b/src/core/reply-target.ts @@ -263,7 +263,7 @@ export function beginReplyTargetTurn( replyRootId: string | undefined, turnId: string, nowIso = new Date().toISOString(), - opts?: { quoteOnly?: boolean; substitute?: boolean; senderOpenId?: string; participants?: TurnParticipant[]; participantsIncomplete?: boolean }, + opts?: { quoteOnly?: boolean; substitute?: boolean; senderOpenId?: string; participants?: TurnParticipant[]; participantsIncomplete?: boolean; inThread?: boolean; foldedRootId?: string }, ): void { // #597: the frozen per-turn dispatch context — the authoritative reply target // for THIS turn's Codex App dispatch (steer/queued/opening). Independent of the @@ -287,6 +287,13 @@ export function beginReplyTargetTurn( ...(ds.session.quoteTargetSenderIsBot !== undefined ? { replyTargetSenderIsBot: ds.session.quoteTargetSenderIsBot } : {}), + // Chat-scope only: distinguishes "answered flat AT TOP LEVEL" from + // "answered flat but the inbound was already inside a topic" (a native + // topic seed). Thread-scope turns route off session.rootMessageId and + // never consult this, so recording it there would only be dead metadata. + ...(ds.scope === 'chat' && opts?.inThread !== undefined + ? { inThread: opts.inThread } + : {}), }; const overflow = Object.keys(exactContexts).length - 256; if (overflow > 0) { @@ -334,6 +341,23 @@ export function beginReplyTargetTurn( ds.session.currentReplyTarget = target; return; } + // The turn folded into this chat-scope session from a Lark thread whose root + // we deliberately did NOT anchor the visible reply to (an after-the-fact + // topic — see chatSessionAnsweredRootAtTopLevel). Routing and display are + // separate contracts: the reply stays flat, but this session must still be + // discoverable by that root, or a later NON-@ message inside that topic + // misses `findChatReplyAlias` and forks a brand-new thread-scope session + // instead of folding back here (reachable under the never/topic/ambient + // mention modes, which answer un-@'d messages). + if (opts?.foldedRootId) { + const aliases = { ...(ds.replyThreadAliases ?? ds.session.replyThreadAliases ?? {}) }; + aliases[opts.foldedRootId] = { + createdAt: aliases[opts.foldedRootId]?.createdAt ?? nowIso, + lastUsedAt: nowIso, + }; + ds.replyThreadAliases = aliases; + ds.session.replyThreadAliases = aliases; + } ds.currentReplyTarget = undefined; ds.session.currentReplyTarget = undefined; } @@ -471,6 +495,10 @@ export function rehomeReplyTargetState(ds: DaemonSession): void { ...(context.replyTargetSenderIsBot !== undefined ? { replyTargetSenderIsBot: context.replyTargetSenderIsBot } : {}), + // `inThread` is deliberately NOT carried over: it describes the shape of + // the inbound message in the SOURCE chat, which says nothing about the + // new destination. Readers treat its absence as "unknown" and fall back + // to pre-existing behavior, which is the safe direction here. }; } ds.session.turnReplyContexts = contexts; @@ -499,3 +527,36 @@ export function rehomeReplyTargetState(ds: DaemonSession): void { ds.streamCardReplyTargetKey = undefined; ds.session.streamCardReplyTargetKey = undefined; } + +/** + * True when `rootId` is a message this chat-scope session ALREADY answered as a + * flat turn **that arrived at the group's top level** — its frozen per-turn + * record is `{ target.mode: 'plain', inThread: false }`. + * + * Lives beside `beginReplyTargetTurn`, the sole writer of the record it reads: + * the two halves of this contract must not drift apart, and a predicate defined + * next to its producer can be unit-tested against real records instead of being + * re-implemented (and silently diverging) at the call site. + * + * Used by the regular-group fold to tell apart: + * • 顶层 @ 之后用户才在那条消息上「开启话题」 — Lark then delivers that topic's + * messages as root_id=<原顶层消息> + thread_id=<新建 omt_>. The turn still + * folds into the group chat-scope session, but the visible reply must NOT be + * anchored into a topic the user never @'d the bot in. + * • 用户真正开的原生话题 — must keep its existing topic anchoring. + * + * `mode === 'plain'` alone cannot separate the two: a native-topic seed is + * recorded `plain` as well (its opening message carries thread_id but no + * root_id, so neither the fold nor the shared-topic seeder supplies a + * replyRootId). `inThread` is the load-bearing half. A record written before + * `inThread` existed leaves it undefined — not `false` — so it reads as + * "unknown" and keeps the pre-existing behavior rather than guessing. + */ +export function chatSessionAnsweredRootAtTopLevel( + s: Pick, + rootId: string, +): boolean { + if (s.scope !== 'chat') return false; + const context = s.turnReplyContexts?.[rootId]; + return context?.target?.mode === 'plain' && context.inThread === false; +} diff --git a/src/daemon.ts b/src/daemon.ts index 3da3aad1e..575066df1 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -278,7 +278,7 @@ import { claimInitialUserTurn, isInitialUserTurnPending, releaseInitialUserTurn import { applyQueuedCodexAppLegacyFallback, mergeQueuedCodexAppTurn } from './core/session-create.js'; import { fillNativeTopicId } from './core/native-topic-id.js'; import { findOnlineDaemon, listOnlineDaemons } from './utils/daemon-discovery.js'; -import { beginReplyTargetTurn, buildTurnParticipantsFrom, fallbackTurnId, isSubstituteTurn, resolveInboundReplyTarget, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js'; +import { beginReplyTargetTurn, buildTurnParticipantsFrom, chatSessionAnsweredRootAtTopLevel, fallbackTurnId, isSubstituteTurn, resolveInboundReplyTarget, resolveSessionReplyTarget, syncReplyTargetState } from './core/reply-target.js'; import { readDeferredTopicBinding } from './core/deferred-topic-binding.js'; import { buildBotmuxLarkNativeSessionTitle, @@ -17200,6 +17200,9 @@ function deliverPassthroughToExistingSession( senderOpenId?: string; senderIsBot: boolean; substitute: boolean; + /** The inbound message carried a Lark thread_id (see + * FrozenSessionReplyContext.inThread). */ + inThread?: boolean; /** raw input 已写入 worker 后回调(worker 不在线的拒绝分支不触发),供 ingress * 调用方打接纳标——其后同步收尾(落盘/事件派发)抛错不得再诱导重发,否则 * /compact 这类非幂等 passthrough 会被重发重复执行。 */ @@ -17224,6 +17227,7 @@ function deliverPassthroughToExistingSession( senderOpenId: turn.senderOpenId, participants: passthroughWindow.participants, participantsIncomplete: passthroughWindow.incomplete, + inThread: turn.inThread, }); if (turn.senderOpenId && ds.session.lastCallerOpenId !== turn.senderOpenId) { ds.session.lastCallerOpenId = turn.senderOpenId; @@ -17388,7 +17392,7 @@ async function startInitialPassthroughSession(args: { sessionStore.updateSession(ds.session); } const initialWindow = buildTurnParticipants(larkAppId, senderOpenId, resolvedSenderIsBotTriState, undefined, initialPassthroughSender?.name); - beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { senderOpenId, participants: initialWindow.participants, participantsIncomplete: initialWindow.incomplete }); + beginReplyTargetTurn(ds, replyRootId, messageId, new Date().toISOString(), { senderOpenId, participants: initialWindow.participants, participantsIncomplete: initialWindow.incomplete, inThread: !!parsed.threadId }); sessionStore.updateSession(ds.session); const registration = await claimNewDaemonSession(activeSessions, ds); if (!registration.accepted) { @@ -18350,7 +18354,7 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise markIngressAdmitted(ctx), }); } @@ -19706,7 +19711,7 @@ async function handleThreadReplyAdmitted( // on the double-race (matches the new-topic path's collectPostAtMentions args). const existingPostAt = prepared?.postParticipantMentions ?? collectPostAtMentions(data?.message, ctx.forwardSeedData?.message); const existingWindow = buildTurnParticipants(larkAppId, callerOpenId, senderIsBotTriState(parsed.senderType, isForeignBot), parsed.mentions, undefined, existingPostAt); - beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: callerOpenId, participants: existingWindow.participants, participantsIncomplete: existingWindow.incomplete }); + beginReplyTargetTurn(ds, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: callerOpenId, participants: existingWindow.participants, participantsIncomplete: existingWindow.incomplete, inThread: !!parsed.threadId, foldedRootId: ctx.foldedRootId }); if (callerOpenId && ds.session.lastCallerOpenId !== callerOpenId) { ds.session.lastCallerOpenId = callerOpenId; } @@ -20140,7 +20145,7 @@ async function handleThreadReplyAdmitted( : 'thread'; const autoCreatePostAt = prepared?.postParticipantMentions ?? collectPostAtMentions(data?.message, ctx.forwardSeedData?.message); const autoCreateWindow = buildTurnParticipants(larkAppId, senderOId, senderIsBotTriState(parsed.senderType, isForeignBot), parsed.mentions, autoCreateSender?.name, autoCreatePostAt); - beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: senderOId, participants: autoCreateWindow.participants, participantsIncomplete: autoCreateWindow.incomplete }); + beginReplyTargetTurn(newDs, replyRootId, parsed.messageId, new Date().toISOString(), { quoteOnly: substituteReplyMode === 'quote', substitute: !!substituteTrigger, senderOpenId: senderOId, participants: autoCreateWindow.participants, participantsIncomplete: autoCreateWindow.incomplete, inThread: !!parsed.threadId, foldedRootId: ctx.foldedRootId }); sessionStore.updateSession(newDs.session); const registration = await claimNewDaemonSession(activeSessions, newDs); if (!registration.accepted) { @@ -22347,6 +22352,17 @@ export async function startDaemon(botIndex?: number): Promise { beforeSessionTurn: (data, ctx) => maybeCatchUpVcMeetingConsumerBeforeTurn(data, ctx), isSessionOwner: (anchor, appId) => activeSessions.has(sessionKey(anchor, appId)), resolveReplyThreadAlias: (rootId, chatId, appId) => findChatReplyAlias(rootId, chatId, appId), + chatSessionAnsweredRootAtTopLevel: (rootId, chatId, appId) => { + for (const ds of activeSessions.values()) { + if (ds.larkAppId !== appId || ds.scope !== 'chat' || ds.chatId !== chatId) continue; + if (chatSessionAnsweredRootAtTopLevel(ds.session, rootId)) return true; + } + return sessionStore.listSessions().some(s => + s.status === 'active' + && s.larkAppId === appId + && s.chatId === chatId + && chatSessionAnsweredRootAtTopLevel(s, rootId)); + }, // Chat was converted 普通群 → 话题群 while we held a chat-scope session. // Idle legacy owners are evicted so subsequent inbound messages land on // fresh thread-scope sessions. Owners with accepted/pending work remain diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index e0a0dde50..11c657a72 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -2093,6 +2093,12 @@ export interface RoutingContext { anchor: string; /** Chat-scope shared-topic reply target for this turn, if any. */ replyRootId?: string; + /** Set when the turn folded into the group chat-scope session from a Lark + * thread whose root was deliberately NOT used as `replyRootId` (an + * after-the-fact topic). The reply stays flat, but the session must still be + * registered under this root so a later NON-@ message inside that topic + * folds back here instead of forking a new thread-scope session. */ + foldedRootId?: string; /** Command prompt that should be sent to the CLI instead of raw text. */ promptOverride?: string; /** Durable VC routing succeeded but the bounded pre-turn catch-up did not. @@ -2402,6 +2408,13 @@ export interface EventHandlers { isSessionOwner?: (anchor: string, larkAppId: string) => boolean; /** Resolve a persisted topic reply alias back to its owning chat-scope session. */ resolveReplyThreadAlias?: (rootId: string, chatId: string, larkAppId: string) => { chatId: string; sessionId: string; anchor?: string } | null; + /** True when this chat's chat-scope session already answered `rootId` as a FLAT + * top-level turn. Identifies the "user @'d at top level, then opened a 话题 on + * that same message" case, where the regular-group fold must keep the turn in + * the group session (as it already does) but must NOT anchor the visible reply + * into the after-the-fact topic. Genuine "@ inside an existing topic" turns + * never match. Best-effort: absent handler ⇒ legacy behavior. */ + chatSessionAnsweredRootAtTopLevel?: (rootId: string, chatId: string, larkAppId: string) => boolean; /** Fired when the dispatcher detects that a chat with a live chat-scope * session has been converted to topic mode (chat_mode 'group' → 'topic' * via Lark group settings). Daemon should evict the stale chat-scope @@ -2576,12 +2589,14 @@ async function maybeFoldMentionedRegularGroupThreadToChat(input: { chatId: string; chatType: 'group' | 'p2p'; message: any; - routing: { scope: 'thread' | 'chat'; anchor: string }; + routing: { scope: 'thread' | 'chat'; anchor: string; foldedRootId?: string }; forceTopicApplied?: boolean; mentionedThisBot: boolean; ownsThreadSession?: boolean; + /** See EventHandlers.chatSessionAnsweredRootAtTopLevel. */ + answeredRootAtTopLevel?: (rootId: string) => boolean; }): Promise { - const { larkAppId, chatId, chatType, message, routing, forceTopicApplied, mentionedThisBot, ownsThreadSession } = input; + const { larkAppId, chatId, chatType, message, routing, forceTopicApplied, mentionedThisBot, ownsThreadSession, answeredRootAtTopLevel } = input; if (forceTopicApplied || ownsThreadSession) return undefined; if (!mentionedThisBot) return undefined; if (chatType !== 'group') return undefined; @@ -2618,6 +2633,27 @@ async function maybeFoldMentionedRegularGroupThreadToChat(input: { if (freshMode !== 'group') return undefined; routing.scope = 'chat'; routing.anchor = chatId; + // 用户先在顶层 @ 了 bot(bot 已按 mode='plain' 平铺答过这条消息),之后才在 + // **同一条消息上手动开启话题**:飞书把话题内的后续消息投递成 + // root_id=<那条原顶层消息> + thread_id=<新建 omt_>。会话折叠回群是对的(上面 + // 已完成),但此时不能再把可见回复钉进这个事后创建的话题 —— 否则用户在顶层 + // @ 得到的回复会跑进一个他并未在其中 @ 过 bot 的话题里。 + // + // 判据要求 `inThread === false`(答那轮的 inbound 不带 thread_id),所以 + // 「@ 在既有话题里」不会命中:原生话题的开场消息虽然同样记 mode='plain',但它 + // 带 thread_id ⇒ inThread=true。既有的话题锚定契约(chat/shared fold 用例) + // 因此保持不变。 + // + // 只抑制**显示锚点**,不抑制**路由归属**:仍把 rootId 作为 foldedRootId 交出去 + // 登记 alias,否则该话题内后续的非 @ 消息会查不到本会话而另起 thread 会话。 + if (answeredRootAtTopLevel?.(rootId)) { + logger.info( + `[reply-mode] thread root=${rootId.substring(0, 12)} was answered flat at top level; ` + + `folding into chat=${chatId.substring(0, 12)} WITHOUT anchoring the reply into the after-the-fact topic`, + ); + routing.foldedRootId = rootId; + return undefined; + } logger.info(`[reply-mode] mentioned thread root=${rootId.substring(0, 12)} folds into chat=${chatId.substring(0, 12)}`); return rootId; } @@ -3356,6 +3392,7 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin const botTalk = evaluateBotTalk(larkAppId, chatId, senderOpenId, senderUnionId); let replyRootId = await maybeFoldMentionedRegularGroupThreadToChat({ larkAppId, chatId, chatType, message, routing: ctx, forceTopicApplied: forcedTopic, mentionedThisBot: botTalk.allowed, ownsThreadSession, + answeredRootAtTopLevel: root => handlers.chatSessionAnsweredRootAtTopLevel?.(root, chatId, larkAppId) ?? false, }); if (!replyRootId) { replyRootId = await maybeApplySharedTopicSeed({ @@ -3677,6 +3714,7 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin : false; const foldedReplyRootId = await maybeFoldMentionedRegularGroupThreadToChat({ larkAppId, chatId, chatType, message, routing, forceTopicApplied, mentionedThisBot: explicitlyMentionedThisBot, ownsThreadSession: ownsThreadSessionBeforeFold, + answeredRootAtTopLevel: root => handlers.chatSessionAnsweredRootAtTopLevel?.(root, chatId, larkAppId) ?? false, }); if (foldedReplyRootId) { replyRootId = foldedReplyRootId; diff --git a/src/services/send-policy.ts b/src/services/send-policy.ts index d4caf3c1a..08be5335e 100644 --- a/src/services/send-policy.ts +++ b/src/services/send-policy.ts @@ -35,6 +35,55 @@ export function resolveQuoteTarget(args: QuoteTargetArgs): string | null { return target && target.trim() ? target.trim() : null; } +export interface AfterTheFactTopicQuoteArgs { + /** The message id this send would quote (null ⇒ nothing to decide). */ + quoteTargetId: string | null; + /** + * The frozen per-turn record's `inThread` for the turn that produced + * `quoteTargetId`: did the inbound message arrive from INSIDE a topic? + * `undefined` = unknown (pre-`inThread` session row). + */ + quotedTurnInThread?: boolean; + /** + * `thread_id` the quote target carries RIGHT NOW, freshly probed from Lark. + * `null` = confirmed no topic. `undefined` = probe failed / not attempted. + */ + currentThreadId?: string | null; + /** An explicit `--quote ` is the operator's own choice; never override it. */ + explicitQuote?: string; +} + +/** + * Whether a chat-scope send must DROP its quote and post flat instead. + * + * Lark's reply API makes a reply inherit the **current** topic membership of the + * message it quotes — `reply_in_thread: false` only declines to OPEN a new + * topic, it cannot escape an existing one. So when the user @s the bot at group + * top level and only AFTERWARDS opens a 话题 on that very message, quoting it + * drops the answer into a topic the user never @'d the bot in (the same + * user-reported bug the regular-group fold fixes on the dispatcher side — this + * is its `botmux send` half, which owns the visible prose reply). + * + * Requires BOTH halves, so it can only ever fire on the exact reported case: + * • the quoted turn arrived at top level (`inThread === false`), and + * • that message NOW carries a `thread_id` — i.e. the topic appeared later. + * + * Fails toward the pre-existing behavior (keep quoting) whenever either half is + * unknown: an old session row has no `inThread`, and a failed/skipped probe + * leaves `currentThreadId` undefined. Quoting is the long-standing default, so + * uncertainty must never silently change where every normal reply lands. + */ +export function shouldDropAfterTheFactTopicQuote(args: AfterTheFactTopicQuoteArgs): boolean { + if (!args.quoteTargetId) return false; + // `--quote ` is an explicit operator instruction; honor it verbatim. + if (args.explicitQuote) return false; + // Only a turn PROVEN to have arrived at top level can be a victim here. + // `undefined` (legacy row) must keep the old behavior, never guess. + if (args.quotedTurnInThread !== false) return false; + // The topic must actually exist now. `undefined` = we don't know ⇒ keep quoting. + return typeof args.currentThreadId === 'string' && args.currentThreadId.trim().length > 0; +} + export interface ManagedVcQuoteArgs { managed: boolean; durableDelivery: boolean; diff --git a/src/types.ts b/src/types.ts index 444fb1ad4..05bbaa7c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -981,6 +981,22 @@ export interface FrozenSessionReplyContext { quoteTargetId?: string; replyTargetSenderOpenId?: string; replyTargetSenderIsBot?: boolean; + /** + * The inbound message that opened this turn already carried a Lark + * `thread_id` — i.e. it arrived from INSIDE a topic, not from the group's + * flat top level. + * + * `target.mode` alone cannot express this: a chat-scope turn records + * `mode:'plain'` both for a genuine top-level @ AND for a native-topic seed + * (whose opening message carries thread_id but no root_id, so neither the + * regular-group fold nor the shared-topic seeder supplies a replyRootId). + * Only the pair (`mode==='plain'` && `inThread !== true`) means "this session + * answered that message flat, AT TOP LEVEL". + * + * Written for chat-scope turns only; absent on older persisted rows, where it + * reads as "unknown" and callers must fail toward the pre-existing behavior. + */ + inThread?: boolean; } /** Host-side destination frozen when a Codex App turn crosses daemon diff --git a/test/daemon-turn-reply-sender-wiring.test.ts b/test/daemon-turn-reply-sender-wiring.test.ts index 944342f9a..b02e60e49 100644 --- a/test/daemon-turn-reply-sender-wiring.test.ts +++ b/test/daemon-turn-reply-sender-wiring.test.ts @@ -33,6 +33,25 @@ describe('daemon per-turn reply sender + participant wiring', () => { expect(daemonSource).toMatch(/participants: autoCreateWindow\.participants, participantsIncomplete: autoCreateWindow\.incomplete/); }); + it('每条 inbound 路径都把「消息是否来自话题内」如实记进 per-turn 记录', () => { + // chatSessionAnsweredRootAtTopLevel 靠 inThread 区分「顶层 @ 之后才被开成 + // 话题」与「用户真正开的原生话题」——两者的 target 都是 mode='plain',只有 + // 这一位能分开。任何一条路径把它写死成常量(而不是照实读 parsed.threadId), + // 判据就会在那条路径上重新退化成只看 mode,真话题里的回复又会被平铺出去。 + // 所以逐条钉住「值来自 inbound 本身」,而不是只钉「字段存在」。 + const inThreadFromInbound = /inThread: !!parsed\.threadId/g; + // initial passthrough / new-topic / existing-session / auto-create 四条 + // beginReplyTargetTurn 直连路径,外加 passthrough 经 turn 结构体的透传。 + expect(daemonSource.match(inThreadFromInbound) ?? []).toHaveLength(5); + expect(daemonSource).toMatch(/participants: initialWindow\.participants, participantsIncomplete: initialWindow\.incomplete, inThread: !!parsed\.threadId/); + expect(daemonSource).toMatch(/participants: newTopicWindow\.participants, participantsIncomplete: newTopicWindow\.incomplete, inThread: !!parsed\.threadId/); + expect(daemonSource).toMatch(/participants: existingWindow\.participants, participantsIncomplete: existingWindow\.incomplete, inThread: !!parsed\.threadId/); + expect(daemonSource).toMatch(/participants: autoCreateWindow\.participants, participantsIncomplete: autoCreateWindow\.incomplete, inThread: !!parsed\.threadId/); + // passthrough 走 turn 结构体:调用方读 inbound,helper 原样转交。 + expect(daemonSource).toMatch(/substitute: !!substituteTrigger,\s*inThread: !!parsed\.threadId,/); + expect(daemonSource).toMatch(/participantsIncomplete: passthroughWindow\.incomplete,\s*inThread: turn\.inThread,/); + }); + it('BOTH registration-race loser handoffs preserve the pre-extracted seed+follow-up post @s', () => { // Two CAS-loser handoffs (new-topic loser and the auto-create loser) must EACH // preserve the complete seed's post inline @s, or a double-race drops them. diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index 469130e27..19d9a3a9b 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -868,6 +868,7 @@ function setupBotState(opts?: { isSessionOwner: ReturnType; onChatModeConverted: ReturnType; resolveReplyThreadAlias: ReturnType; + chatSessionAnsweredRootAtTopLevel: ReturnType; handleVcMeetingPush: ReturnType; } { return { @@ -877,6 +878,7 @@ function setupBotState(opts?: { handleVcMeetingPush: vi.fn(async () => {}), isSessionOwner: vi.fn(() => false), resolveReplyThreadAlias: vi.fn(() => null), + chatSessionAnsweredRootAtTopLevel: vi.fn(() => false), onChatModeConverted: vi.fn(), }; } @@ -4302,6 +4304,198 @@ describe('im.message.receive_v1 — bot-to-bot @mention routing', () => { expect(handlers.handleNewTopic).not.toHaveBeenCalled(); }); + it('chat mode: a topic opened ON an earlier top-level @ keeps the reply at top level (root_id is an om_ message, not the omt_ thread)', async () => { + // Regression (user-reported): 顶层 @bot 建立 chat-scope 会话后,用户在**同一条 + // 顶层消息上手动「开启话题」**。飞书随后把该话题内的消息投递成 + // root_id = 那条原顶层 om_ 消息、thread_id = 新建的 omt_ 话题 —— 即 + // root_id !== thread_id,且 root_id 是一条普通 om_ 消息。 + // + // 与上一个用例(root_id === thread_id === 既有话题根,回复必须留在话题里) + // 的区别就在这里:那是「消息本就诞生在既有话题内」,而这里的话题是在 bot 已 + // 按顶层建立会话之后才出现的。此时 fold 判定折叠回群是对的(scope=chat), + // 但不能再把可见回复钉进这个事后创建的话题 —— 否则用户在顶层 @ 得到的回复 + // 会跑进一个他并未在其中 @ 过 bot 的话题里。 + setupBotState({ regularGroupReplyMode: 'chat', allowedUsers: [USER_OPEN_ID] }); + mockGetChatMode.mockResolvedValue('group'); + handlers.isSessionOwner.mockImplementation((anchor: string) => anchor === 'chat-after-topic'); + // 前提:bot 此前已按顶层平铺答过 om_earlier_top_level_at 这条消息 + // (daemon 侧即 turnReplyContexts[root].target.mode === 'plain')。 + handlers.chatSessionAnsweredRootAtTopLevel.mockImplementation( + (rootId: string) => rootId === 'om_earlier_top_level_at', + ); + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA follow up after I opened a topic' }), + // root_id 指向此前那条顶层 @ 消息(om_ 前缀),thread_id 是事后新建的话题 + rootId: 'om_earlier_top_level_at', + threadId: 'omt_opened_afterwards', + messageId: 'msg-after-topic-opened', + chatId: 'chat-after-topic', + chatType: 'group', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + + const call = handlers.handleThreadReply.mock.calls.find(c => c[0] === event) + ?? handlers.handleNewTopic.mock.calls.find(c => c[0] === event); + expect(call).toBeTruthy(); + // 会话仍折叠回群 chat-scope(这部分本来就是对的) + expect(call![1]).toEqual(expect.objectContaining({ + scope: 'chat', + anchor: 'chat-after-topic', + larkAppId: MY_APP_ID, + })); + // 关键断言:不得把回复钉进事后创建的话题 + expect(call![1].replyRootId).toBeUndefined(); + }); + + // ── 判据本体的真机制覆盖 ───────────────────────────────────────────── + // 上一个用例把 chatSessionAnsweredRootAtTopLevel 当 mock 喂死值,验的是 + // 「判据命中之后 dispatcher 怎么做」。但「判据在什么输入下才该命中」同样是 + // 契约的一半,且只 mock 的话它零覆盖 —— 曾因此漏掉原生话题 seed 也被记成 + // mode='plain' 的情况。所以下面这组用真 beginReplyTargetTurn 落记录、再喂给 + // **真判据本体**(从 reply-target.js 导入 daemon 用的同一个函数,绝不在测试里 + // 手抄一份 —— 手抄的副本会让判据本体的变异照样全绿)。 + const chatScopeDs = (chatId: string): any => ({ + scope: 'chat', + chatId, + session: { + sessionId: `sess-${chatId}`, chatId, rootMessageId: chatId, title: 't', + status: 'active', createdAt: new Date().toISOString(), scope: 'chat', + }, + }); + + it('判据: 顶层 @ 那轮(无 thread_id)记为 plain+inThread:false → 命中', async () => { + const { beginReplyTargetTurn, chatSessionAnsweredRootAtTopLevel: answeredAtTopLevel } = await import('../src/core/reply-target.js'); + const ds = chatScopeDs('oc_toplevel'); + // daemon 顶层 @ 路径:replyRootId=undefined、inThread=!!parsed.threadId=false + beginReplyTargetTurn(ds, undefined, 'om_top_at', new Date().toISOString(), { inThread: false }); + expect(ds.session.turnReplyContexts['om_top_at']) + .toMatchObject({ target: { mode: 'plain', chatId: 'oc_toplevel' }, inThread: false }); + expect(answeredAtTopLevel(ds.session, 'om_top_at')).toBe(true); + }); + + it('判据: chat 模式原生话题 seed 同样记 plain,但 inThread:true → 不命中(真话题不被平铺)', async () => { + const { beginReplyTargetTurn, chatSessionAnsweredRootAtTopLevel: answeredAtTopLevel } = await import('../src/core/reply-target.js'); + const ds = chatScopeDs('oc_native'); + // chat 模式下原生话题的开场消息:thread_id=omt_* 但没有 root_id,于是 + // maybeFold(缺 root_id)与 shared-seed(mode≠shared)双双早退 ⇒ replyRootId + // 仍是 undefined ⇒ target 同样是 plain。唯一能区分它的就是 inThread。 + beginReplyTargetTurn(ds, undefined, 'om_native_seed', new Date().toISOString(), { inThread: true }); + expect(ds.session.turnReplyContexts['om_native_seed'].target).toEqual({ mode: 'plain', chatId: 'oc_native' }); + expect(answeredAtTopLevel(ds.session, 'om_native_seed')).toBe(false); + }); + + it('判据: 老会话记录没有 inThread 字段 → 不命中(fail toward 既有话题锚定)', async () => { + const { chatSessionAnsweredRootAtTopLevel: answeredAtTopLevel } = await import('../src/core/reply-target.js'); + const ds = chatScopeDs('oc_legacy'); + // PR 之前落盘的记录只有 target,没有 inThread。undefined !== false, + // 因此按「未知」处理、保持旧行为,而不是猜它是顶层。 + ds.session.turnReplyContexts = { om_legacy: { target: { mode: 'plain', chatId: 'oc_legacy' } } }; + expect(answeredAtTopLevel(ds.session, 'om_legacy')).toBe(false); + }); + + it('判据: 话题内被 @ 那轮按回复消息 id 记 thread 目标,root 本身查不到 → 不命中', async () => { + const { beginReplyTargetTurn, chatSessionAnsweredRootAtTopLevel: answeredAtTopLevel } = await import('../src/core/reply-target.js'); + const ds = chatScopeDs('oc_infold'); + // fold 路径:beginReplyTargetTurn(ds, replyRootId=rootId, turnId=messageId) + beginReplyTargetTurn(ds, 'om_existing_root', 'om_reply_msg', new Date().toISOString(), { inThread: true }); + expect(answeredAtTopLevel(ds.session, 'om_existing_root')).toBe(false); + expect(ds.session.turnReplyContexts['om_reply_msg'].target) + .toEqual({ mode: 'thread', rootMessageId: 'om_existing_root' }); + }); + + it('端到端: 真记录 + 真判据 —— 事后开的话题被平铺,用户真开的话题保持锚定', async () => { + const { beginReplyTargetTurn, chatSessionAnsweredRootAtTopLevel: answeredAtTopLevel } = await import('../src/core/reply-target.js'); + const now = new Date().toISOString(); + const ds = chatScopeDs('chat-e2e'); + // 会话历史里两条都被平铺答过,区别只在 inThread: + beginReplyTargetTurn(ds, undefined, 'om_top_level_at', now, { inThread: false }); // 顶层 @ + beginReplyTargetTurn(ds, undefined, 'om_native_seed', now, { inThread: true }); // 原生话题 seed + + setupBotState({ regularGroupReplyMode: 'chat', allowedUsers: [USER_OPEN_ID] }); + mockGetChatMode.mockResolvedValue('group'); + handlers.isSessionOwner.mockImplementation((a: string) => a === 'chat-e2e'); + // 关键:注入的不是死值,而是真判据跑在真记录上 + handlers.chatSessionAnsweredRootAtTopLevel.mockImplementation( + (rootId: string) => answeredAtTopLevel(ds.session, rootId), + ); + + const inbound = (rootId: string, messageId: string) => makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA follow up' }), + rootId, + threadId: 'omt_some_topic', + messageId, + chatId: 'chat-e2e', + chatType: 'group', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + const ctxFor = (event: any) => ( + handlers.handleThreadReply.mock.calls.find(c => c[0] === event) + ?? handlers.handleNewTopic.mock.calls.find(c => c[0] === event) + )?.[1]; + + // ① 事后在顶层 @ 上开的话题 → 回复平铺回顶层 + const afterFact = inbound('om_top_level_at', 'msg-after-fact'); + await capturedHandlers['im.message.receive_v1'](afterFact); + await flushEventWork(); + expect(ctxFor(afterFact)).toMatchObject({ scope: 'chat', anchor: 'chat-e2e' }); + expect(ctxFor(afterFact).replyRootId).toBeUndefined(); + + // ② 用户真正开的原生话题 → 回复仍锚在该话题里(既有契约不受影响) + const genuine = inbound('om_native_seed', 'msg-in-genuine-topic'); + await capturedHandlers['im.message.receive_v1'](genuine); + await flushEventWork(); + expect(ctxFor(genuine)).toMatchObject({ scope: 'chat', anchor: 'chat-e2e', replyRootId: 'om_native_seed' }); + }); + + it('抑制显示锚点时仍交出 foldedRootId,且真 producer 据此登记 alias(话题内非@消息能折回本会话)', async () => { + const { beginReplyTargetTurn, chatSessionAnsweredRootAtTopLevel: answeredAtTopLevel } = await import('../src/core/reply-target.js'); + const now = new Date().toISOString(); + const ds = chatScopeDs('chat-alias'); + beginReplyTargetTurn(ds, undefined, 'om_top_at', now, { inThread: false }); + + setupBotState({ regularGroupReplyMode: 'chat', allowedUsers: [USER_OPEN_ID] }); + mockGetChatMode.mockResolvedValue('group'); + handlers.isSessionOwner.mockImplementation((a: string) => a === 'chat-alias'); + handlers.chatSessionAnsweredRootAtTopLevel.mockImplementation( + (rootId: string) => answeredAtTopLevel(ds.session, rootId), + ); + + const event = makeUserMessageEvent({ + senderOpenId: USER_OPEN_ID, + content: JSON.stringify({ text: '@BotA follow up' }), + rootId: 'om_top_at', + threadId: 'omt_after_fact', + messageId: 'msg-alias-case', + chatId: 'chat-alias', + chatType: 'group', + mentions: [{ key: '@_bot_a', name: 'BotA', id: { open_id: MY_OPEN_ID } }], + }); + await capturedHandlers['im.message.receive_v1'](event); + await flushEventWork(); + const ctx = ( + handlers.handleThreadReply.mock.calls.find(c => c[0] === event) + ?? handlers.handleNewTopic.mock.calls.find(c => c[0] === event) + )?.[1]; + // 显示锚点被抑制(回复平铺),但路由归属仍交出去 + expect(ctx.replyRootId).toBeUndefined(); + expect(ctx.foldedRootId).toBe('om_top_at'); + + // 真 producer 拿到 foldedRootId 后必须登记 alias,否则该话题内的非 @ 消息 + // 查不到本会话会另起 thread 会话 + beginReplyTargetTurn(ds, ctx.replyRootId, 'msg-alias-case', now, { + inThread: true, + foldedRootId: ctx.foldedRootId, + }); + expect(ds.session.replyThreadAliases?.['om_top_at']).toBeTruthy(); + // 抑制显示锚点的语义不变:不设 currentReplyTarget + expect(ds.session.currentReplyTarget).toBeUndefined(); + }); + it('new-topic mode keeps @ inside a regular-group topic as an independent thread session', async () => { setupBotState({ regularGroupReplyMode: 'new-topic', allowedUsers: [USER_OPEN_ID] }); mockGetChatMode.mockResolvedValue('group'); diff --git a/test/send-after-the-fact-topic-quote-wiring.test.ts b/test/send-after-the-fact-topic-quote-wiring.test.ts new file mode 100644 index 000000000..65908501a --- /dev/null +++ b/test/send-after-the-fact-topic-quote-wiring.test.ts @@ -0,0 +1,69 @@ +/** + * `botmux send` 侧「事后开的话题」拦截的**接线**回归。 + * + * 为什么需要这个文件:`shouldDropAfterTheFactTopicQuote` 是纯函数、单测很好写, + * 但纯函数全绿**不能**证明 cmdSend 真的用了它 —— 实测把调用点的 + * `effectiveQuoteTargetId = undefined` 注释掉(修复变死代码),send-policy 与 + * cli-send-dispatch 共 92 个测试**全部照常通过**。所以这里按源码钉住整条接线: + * 判据被调用、结论被消费、探测短路条件、以及送进发送函数的是**收敛后**的值。 + * + * Run: bunx vitest run test/send-after-the-fact-topic-quote-wiring.test.ts + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const cliSource = readFileSync(join(__dirname, '..', 'src', 'cli.ts'), 'utf8'); + +describe('botmux send: 事后开的话题不再被 quote 带进去(接线)', () => { + it('判据从 send-policy 导入,不在 cli 里手抄一份', () => { + // 手抄副本会让判据本体的变异测不到 —— 同一个 PR 的 dispatcher 侧已经踩过一次。 + expect(cliSource).toMatch(/import \{[\s\S]{0,400}shouldDropAfterTheFactTopicQuote,/); + // 不得出现就地重写的等价表达式。 + expect(cliSource).not.toMatch(/quotedTurnInThread === false\s*&&\s*typeof/); + }); + + it('inThread 取自会话持久化的 per-turn 记录(优先本轮 turnId,回退 quote 目标)', () => { + expect(cliSource).toMatch( + /const quotedTurnInThread = quoteTargetId\s*\?\s*\(s\.turnReplyContexts\?\.\[currentTurnId \?\? ''\]\?\.inThread\s*\?\?\s*s\.turnReplyContexts\?\.\[quoteTargetId\]\?\.inThread\)/, + ); + }); + + it('只在「确证顶层进来 + 本次真要 quote + 非 --quote」时才探测飞书(热路径不多付调用)', () => { + expect(cliSource).toMatch( + /if \(quoteTargetId && !explicitQuote && quotedTurnInThread === false\) \{/, + ); + expect(cliSource).toMatch(/getMessageThreadId\(appId, quoteTargetId\)\.catch\(\(\) => undefined\)/); + }); + + it('判据命中 → 真的把 quote 收敛掉,而不是算完不用(MUT-E 拆的就是这一行)', () => { + // ⚠️ 不能只 toMatch 一段含该赋值的正则:把那行注释掉后,注释里仍留着同样的 + // 字符,正则照样匹配 —— 实测这么写 98 个测试全绿、变异毫无牙。所以必须**逐行** + // 检查:赋值语句必须以行首缩进开始,不能是 `//` 开头的注释行。 + const lines = cliSource.split('\n'); + const assignLines = lines.filter(l => /effectiveQuoteTargetId = undefined;/.test(l)); + expect(assignLines.length).toBeGreaterThan(0); + // 至少有一行是**真正执行**的赋值(非注释)。 + const live = assignLines.filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l)); + expect(live.length).toBeGreaterThan(0); + // 且它落在判据的 if 块里。 + const idx = lines.findIndex(l => l.includes('shouldDropAfterTheFactTopicQuote({')); + expect(idx).toBeGreaterThan(-1); + const block = lines.slice(idx, idx + 14).filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n'); + expect(block).toMatch(/effectiveQuoteTargetId = undefined;/); + }); + + it('送进发送链的是**收敛后**的值,不是原始 quoteTargetId', () => { + expect(cliSource).toMatch( + /\.\.\.\(effectiveQuoteTargetId \? \{ quoteTargetId: effectiveQuoteTargetId \} : \{\}\)/, + ); + // 原始值不得再直接进 proposedOutput(那样收敛就被绕过了)。 + expect(cliSource).not.toMatch(/\.\.\.\(quoteTargetId \? \{ quoteTargetId \} : \{\}\)/); + }); + + it('探测走 catch 兜底 ⇒ 飞书报错不阻断发送(失败方向=保持既有 quote)', () => { + expect(cliSource).toMatch(/const probedThreadId = await getMessageThreadId\([^)]*\)\.catch/); + }); +}); diff --git a/test/send-policy.test.ts b/test/send-policy.test.ts index c754a9e23..e46377b2b 100644 --- a/test/send-policy.test.ts +++ b/test/send-policy.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { resolveQuoteTarget, + shouldDropAfterTheFactTopicQuote, validateMentionDecision, mentionBackAmbiguity, mentionBackAmbiguityError, @@ -361,3 +362,57 @@ describe('attentionUsageError', () => { expect(attentionUsageError({ ...ok, hasText: false })).toMatch(/reason/); }); }); + +describe('shouldDropAfterTheFactTopicQuote', () => { + // 「顶层 @ 之后那条消息才被开成话题」的发送侧半边:quote 会继承被引用消息 + // **此刻**的话题归属,所以必须在这种情况下放弃 quote、改平铺。 + const base = { quoteTargetId: 'om_top_at' }; + + it('顶层进来的轮次 + 该消息现在已属于话题 → 放弃 quote(改平铺)', () => { + expect(shouldDropAfterTheFactTopicQuote({ + ...base, quotedTurnInThread: false, currentThreadId: 'omt_after_fact', + })).toBe(true); + }); + + it('顶层进来但该消息现在确认没有话题 → 照旧 quote', () => { + expect(shouldDropAfterTheFactTopicQuote({ + ...base, quotedTurnInThread: false, currentThreadId: null, + })).toBe(false); + }); + + it('本轮就是从话题里进来的(inThread=true) → 照旧 quote,不碰真话题的锚定', () => { + expect(shouldDropAfterTheFactTopicQuote({ + ...base, quotedTurnInThread: true, currentThreadId: 'omt_genuine', + })).toBe(false); + }); + + it('老会话行没有 inThread(undefined) → 按未知保持旧行为,绝不猜', () => { + expect(shouldDropAfterTheFactTopicQuote({ + ...base, quotedTurnInThread: undefined, currentThreadId: 'omt_x', + })).toBe(false); + }); + + it('探测失败/未探测(currentThreadId undefined) → 保持 quote,不确定不改行为', () => { + expect(shouldDropAfterTheFactTopicQuote({ + ...base, quotedTurnInThread: false, currentThreadId: undefined, + })).toBe(false); + }); + + it('--quote 是操作者显式指定 → 一律照办,不覆盖', () => { + expect(shouldDropAfterTheFactTopicQuote({ + ...base, quotedTurnInThread: false, currentThreadId: 'omt_after_fact', explicitQuote: 'om_top_at', + })).toBe(false); + }); + + it('本来就不 quote → 无需判断', () => { + expect(shouldDropAfterTheFactTopicQuote({ + quoteTargetId: null, quotedTurnInThread: false, currentThreadId: 'omt_x', + })).toBe(false); + }); + + it('空白 thread_id 不算话题(防把 "" / 空格当命中)', () => { + expect(shouldDropAfterTheFactTopicQuote({ + ...base, quotedTurnInThread: false, currentThreadId: ' ', + })).toBe(false); + }); +}); diff --git a/test/session-store-sqlite.test.ts b/test/session-store-sqlite.test.ts index 066a160a4..976c6f5d5 100644 --- a/test/session-store-sqlite.test.ts +++ b/test/session-store-sqlite.test.ts @@ -35,6 +35,7 @@ vi.mock('../src/services/frozen-card-store.js', () => ({ deleteFrozenCards: (...args: any[]) => mockDeleteFrozenCards(...args), })); +import { chatSessionAnsweredRootAtTopLevel } from '../src/core/reply-target.js'; import { __testOnly_setSqliteUnavailable, assertSqliteSupported, @@ -399,6 +400,34 @@ describe('first-load serialization against an in-flight offline writer', () => { try { child.kill('SIGKILL'); } catch { /* already gone */ } } }, 20_000); + + it('per-turn inThread 三态(false/true/缺失)完整穿过持久层往返', () => { + // chatSessionAnsweredRootAtTopLevel 靠 `inThread === false` 与 + // `inThread === true` / 字段缺失(老行)三者的区别来判「顶层 @ 之后才被开成 + // 话题」——三个值的 target 都是 mode='plain'。任何一环把 false 与 undefined + // 混同(比如序列化时按 falsy 丢字段),判据就会把老行误判成顶层、或把真话题 + // 里的回复平铺出去。所以往返要读**真正落盘的行**,而不是进程内缓存。 + init('appA', { owner: true }); + const s = createSession('oc_group', 'oc_group', 'title', 'group', 'chat'); + s.larkAppId = 'appA'; + s.turnReplyContexts = { + om_top: { target: { mode: 'plain', chatId: 'oc_group' }, inThread: false }, + om_seed: { target: { mode: 'plain', chatId: 'oc_group' }, inThread: true }, + om_legacy: { target: { mode: 'plain', chatId: 'oc_group' } }, + }; + updateSession(s); + + const persisted = readPersistedSessionRows(tempDir, 'appA')[s.sessionId]; + expect(persisted.turnReplyContexts.om_top) + .toEqual({ target: { mode: 'plain', chatId: 'oc_group' }, inThread: false }); + expect(persisted.turnReplyContexts.om_seed.inThread).toBe(true); + expect(persisted.turnReplyContexts.om_legacy.inThread).toBeUndefined(); + + // 判据跑在真正从盘上读回来的行上。 + expect(chatSessionAnsweredRootAtTopLevel(persisted, 'om_top')).toBe(true); + expect(chatSessionAnsweredRootAtTopLevel(persisted, 'om_seed')).toBe(false); + expect(chatSessionAnsweredRootAtTopLevel(persisted, 'om_legacy')).toBe(false); + }); }); // ─── Node 能力探测 ───────────────────────────────────────────────────────────