Skip to content

fix(lark): 顶层@后手动开话题不再把回复钉进事后话题 - #1023

Open
deepcoldy wants to merge 2 commits into
masterfrom
wt/botmux-claude-hi-3
Open

fix(lark): 顶层@后手动开话题不再把回复钉进事后话题#1023
deepcoldy wants to merge 2 commits into
masterfrom
wt/botmux-claude-hi-3

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

问题

普通群 chat / shared reply-mode 下:

  1. 用户在群顶层 @ 机器人 → 机器人正确建立 chat-scope 会话,并以顶层平铺方式回复(正常)
  2. 用户随后在同一条消息上手动「开启话题」
  3. 飞书把该话题内的后续消息投递成 root_id=<那条原顶层消息> + thread_id=<新建的 omt_ 话题>
  4. 此时 maybeFoldMentionedRegularGroupThreadToChat 正确地把会话折叠回群 chat-scope,但同时把 rootId 作为 replyRootId 返回 → 可见回复被 reply_in_thread 钉进一个用户并未在其中 @ 过机器人的话题里

用户预期:@ 机器人那一刻消息在顶层,回复就该留在顶层。

现场证据(daemon 日志中一对自相矛盾的记录)

[reply-mode] mentioned thread root=om_x100b67d2 folds into chat=oc_39f6ce242   ← 会话层:折叠回群 chat-scope
Replied om_... to message om_...  [msgType=interactive, replyInThread=true]     ← 发送层:却把回复锚回话题

会话已落到 chat-scope,可见回复却进了话题——决策与执行不一致。

为什么不能只看 id 形状

最初设想按 root_idom_ 还是 omt_、以及 root_id !== thread_id 来区分,但这条判据是错的:仓库既有用例(test/event-dispatcher.test.ts 的 chat/shared fold)本身就是 root_id(om_) ≠ thread_id(omt_),却要求保留话题锚定(注释原文:keep the visible reply in the same topic, but route the turn through the group chat-scope session)。仅凭 id 前缀/异同无法区分「消息本就诞生在既有话题内」与「话题是事后才开的」,照此修改会打破既有契约。

修法

改用会话自身的记录做判据,不依赖对飞书 id 语义的猜测:

  • daemon 新增 chatSessionAnsweredRootAtTopLevel(rootId, chatId, larkAppId):该 root 是否曾被本 chat-scope 会话以顶层平铺方式答过,即 turnReplyContexts[root].target.mode === 'plain'(活跃会话与磁盘上 active 会话都查)
  • EventHandlers 注入 dispatcher;fold 命中该判据时:会话照旧折叠回群(scope='chat' 不变),但不再设置 replyRootId,回复平铺回顶层
  • 「@ 在既有话题里」永不命中——那种 root 不曾被本会话以顶层平铺方式答过(要么未见过,要么已记为 mode='thread'),因此原有话题锚定契约完整保留

影响面

  • 普通群 chat / shared 两种 reply-mode 的这一条 fold 路径
  • 话题群(topic)、chat-topicnew-topic、p2p 各路径均不受影响
  • 新判据为可选注入,缺省返回 false(未接线时保持旧行为),故不影响其它 CLI 适配器与会话类型
  • 未触碰 owner 身份边界相关代码

验证

均为实际执行结果:

  • 复现测试:修复前失败 AssertionError: expected 'om_earlier_top_level_at' to be undefined;修复后通过
  • test/event-dispatcher.test.ts 301/301 通过(含 chat/shared fold 与 p2p 话题锚定既有契约用例)
  • 相关回归 5 套件 124/124 通过reply-target-fallbackreply-mode-commandsession-group-birth-anchorinitial-user-turn-openingmessage-quota-enforcement
  • rebase 到最新 master 后重跑(master 期间也改过 daemon.ts):上述 6 套件合计 425/425 通过
  • 反向变异:将新 guard 短路为 if (false && ...) → 仅新增用例失败(1 failed / 300 passed),确认该判据真正生效且未被其它 guard 掩盖
  • tsc --noEmit 全仓类型检查通过(exit 0,零输出)

待办

飞书 live 实测尚未进行(需 build + 重启 daemon,会影响全部 bot,待确认部署方式后补充结果)。实测方法:在普通群顶层 @ 机器人 → 等其平铺回复 → 在那条消息上手动开启话题后再 @ 一次 → 回复应落在顶层,不再进入话题。

🤖 Generated with Claude Code

@deepcoldy

Copy link
Copy Markdown
Owner Author

感谢这个修复 —— 问题定位和取舍都很到位,尤其是明确否掉了「按 id 形状(om_/omt_root_id !== thread_id)判断」的方案:仓库既有的 chat/shared fold 用例本身就是 root_id(om_) ≠ thread_id(omt_) 却要求保留话题锚定,按形状改确实会打破既有契约。改用「会话自身的记录」做判据、并保持 scope='chat' 折叠不变而只收敛 replyRootId,方向我认为是对的。

下面是我复核时实测到的两点,想请你看一下(自动评审的初步意见,最终以维护者审阅为准):

1)建议合前修:判据在 chat 模式下会误伤「用户真正开的原生话题」

PR 正文与代码注释都断言「@ 在既有话题里永不命中 —— 那种 root 不曾被本会话以顶层平铺方式答过」。这个断言对 shared 成立,但对 chat 我实测不成立

  • chat 模式下用户开一个原生话题时,话题的开场消息形态是 thread_id=omt_*没有 root_id
  • 于是 maybeFoldMentionedRegularGroupThreadToChat 因缺 root_id 早退,maybeApplySharedTopicSeed 因 mode≠shared 早退 ⇒ 该轮 replyRootId === undefined
  • beginReplyTargetTurn(ds, undefined, seedMessageId) 因此把它记成 { mode: 'plain' }
  • 此后该话题内的 @ 带 root_id = 开场消息的 message_id(既有用例 chat-topic mode: a later reply in that native topic stays anchored at its root 锁了这个形状)⇒ 新判据命中 ⇒ 一个用户确实在其中 @ 过机器人的话题,回复被平铺回顶层。

我把真 beginReplyTargetTurn 的记录与逐字复制的 daemon 判据表达式接到真 dispatcher 上端到端跑过,输出是 predicate(om_native_seed) = truectx.replyRootId = undefined(master 会锚 om_native_seed)。新增用例没暴露这点,是因为它把判据当 mock 直接喂 true,「daemon 在什么输入下会记 plain」这一环没有被覆盖到。

根因是判据缺了「顶层平铺」里的顶层那半位:mode === 'plain' 只证明「答的时候没锚话题」,不证明「答的时候不在话题里」。可用的现成信号是 inbound 的 thread_id —— 顶层 @ 那轮根本没有 thread_id,原生话题 seed 那轮有;message-parser 已经透出 threadIdisNativeTopicId / session.larkThreadId 也都是现成的。把这一位一起纳入判据(或等价地排除已绑定 larkThreadId 的会话),断言就与注释相符了。

2)非阻断,但想确认是否有意取舍:抑制 replyRootId 会连带不登记 replyThreadAliases

beginReplyTargetTurn 只在 replyRootId 存在时写 replyThreadAliases[root]reply-target.ts:325),所以走 undefined 之后 findChatReplyAlias / sessionHasReplyThreadAlias 永远查不到这个 root。我在真 dispatcher 上按 never 模式跑过:该事后话题里不带 @ 的后续消息 alias fold-back 会 miss,落到 handleNewTopicscope='thread', anchor=<那个 root> —— 即在话题里另起一个独立 thread 会话(master 下会 fold 回群会话)。只在 never / topic / ambient 三种 mention 模式下可达(默认 always 不受影响),所以我判非阻断,但值得明确取舍并补一条用例钉住。

已核实通过的部分

  • tsc --noEmit exit=0;test/event-dispatcher.test.ts 301/301;你列的 6 套件复跑 425/425 绿
  • 我另外跑了 6 个 turnReplyContexts 消费方套件(group-join-shared-routing / ordinary-turn-recovery / transfer-session / recall-frozen-cards / session-lifecycle-start / scope-optional-autofix)316/316 绿
  • PR head 已含当前 master tip,git merge-tree --write-tree exit=0,真 0 冲突
  • 影响面同意你的评估:只碰 chat/shared 这一条 fold 路径,注入为可选(缺省 false = 旧行为),话题群 / chat-topic / new-topic / p2p 未受影响,也没碰 owner 身份边界
  • 反向变异我独立跑了三处:guard 短路 if (false && …) → 只有新增用例失败(有牙,没被其它 guard 掩盖);guard 恒真 if (true || …) → 6 个既有 fold/话题锚定用例失败(既有契约确实被测着)。

顺带一个可能有用的提示:第三处变异——把 daemon 判据的 === 'plain' 改成 !!mode,去掉最核心的那一位区分——4 套件 388 测全绿。也就是说判据本体目前没有任何测试盯着(用例喂的是 mock 布尔,天然测不到),这正是上面第 1 点能藏进来的原因。补测时建议直接让真 beginReplyTargetTurn 产出记录、再喂给判据,而不是 mock 判据的返回值。

@deepcoldy

Copy link
Copy Markdown
Owner Author

补一条复核追加意见(同上,自动评审的初步意见,最终以维护者审阅为准)。第二轮独立复核确认了上一条的两点,并把误伤面又扩了一档:

误伤不止 chat 模式:shared + 群聊 @ 策略 topic 同样命中

上一条里我说「注释的『@ 在既有话题里永不命中』对 shared 成立」——这个说法只对默认的 always 策略成立,需要更正。在 shared + regularGroupMentionMode='topic' 组合下,我实测同样会误伤:

  • 用户在群里开一个原生话题,开场消息不带 @thread_id=omt_*、无 root_id);
  • maybeApplySharedTopicSeed 的播种前置条件是「@ 了本 bot,或策略为 never/ambient」——topic 不在放行名单里,所以不播种replyRootId 保持 undefined
  • 但这条消息并不会被丢弃:relax 的 topic 条款(isAllowed && mentionMode==='topic' && ownsSession && !!message.thread_id && !mentionsOther)会放它进来。我在真 dispatcher 上跑到的结果是 handleThreadReply + {scope:'chat', anchor:<chatId>, replyRootId: undefined}
  • 于是真 beginReplyTargetTurn(ds, undefined, seedMsgId) 又记下 mode='plain';此后该话题内的 @(root_id=开场消息 id)⇒ 判据 predicate=truereplyRootId 被抑制,回复被平铺出这个用户真正开的话题。

我把「真 beginReplyTargetTurn 记录 + 逐字 daemon 判据」接进真 dispatcher 跑出的两组值:

  • 开场消息那轮:{handler:'handleThreadReply', scope:'chat', anchor:'chat-pi-ext', replyRootId: undefined}
  • 话题内后续 @:{predicate:true, scope:'chat', anchor:'chat-pi-ext2', replyRootId: undefined}(master 会锚在开场消息上)

所以准确的表述是:注释里那句「永不命中」在 chat(全部 mention 策略)shared + topic 策略 下都不成立,仅在 shared + always/never/ambient 下成立。

修法建议(与上一条一致,只是覆盖面更明确)

根因还是同一个:mode==='plain' 只记录了「答的时候没锚话题」,没记录「答的时候不在话题里」。建议在 beginReplyTargetTurn 落 per-turn 记录时把 inbound 是否带 thread_id 一起冻进去(一个 inThread 位即可),判据改成要求 mode==='plain' 该位为 false。这样两种模式的原生话题 seed 都会被正确排除,而 PR 的目标场景(顶层 @ 那轮压根没有 thread_id)不受影响。

同时建议补一条让真 beginReplyTargetTurn 产出记录、再喂给真判据的测试(而不是 mock 判据返回值)。上一条提到的覆盖洞这轮也复核得更宽了:把判据的 === 'plain' 改成 !!mode 后,7 套件 650 测仍全绿 —— 判据本体目前确实没有任何测试盯着,这也是这两处误伤能同时藏住的原因。

方向仍然认可,改动量不大。

@deepcoldy
deepcoldy force-pushed the wt/botmux-claude-hi-3 branch from ba129d7 to 93396f8 Compare August 28, 2026 03:25
@deepcoldy

Copy link
Copy Markdown
Owner Author

已 rebase 到最新 master 并把上面两条意见里的必修项直接实现了,推在同一分支上(93396f893,前一条 commit 保持你原来的实现不动,修复作为第二条 commit)。

rebase

分支落后 master 79 个 commit(其间仓库做了 pnpm→bun 迁移、session-store 换 SQLite 引擎等)。rebase 后你原来的那条 commit 内容零变化,无冲突;tsc --noEmit exit=0,bun run build 通过。

修法

判据补上「答的时候在顶层」那半位,而不是只看「答的时候没锚话题」:

  • FrozenSessionReplyContext 新增 inThread,由 beginReplyTargetTurn 按 inbound 是否带 thread_id 如实记录(仅 chat scope 写;thread scope 走 session.rootMessageId,不读这一位)
  • 判据改成 mode === 'plain' && inThread === false
  • 判据从 daemon 的私有函数提升为 core/reply-target.ts 的导出,和它唯一的写入方 beginReplyTargetTurn 放在一处。这样两半不会漂移,也让判据本体可以被直接单测——不必在测试里手抄一份(手抄副本会让判据本体的变异照样全绿,我第一版就踩了这个坑)
  • 老会话记录没有该字段:undefined !== false,按「未知」保持既有话题锚定,不猜它是顶层
  • rehomeReplyTargetState 不搬运这一位:它描述的是源会话里 inbound 的形态,换目的地后不成立,落回「未知」是安全方向

五条 inbound 路径(初始 passthrough / 新话题 / 已有会话 / 自动建会话 / passthrough 经 turn 结构体透传)逐条接入。入群首轮和定时任务没有 inbound 消息,不写这一位。

关于第 2 点(replyThreadAliases

保持现状、没有改。抑制 replyRootId 连带不登记 alias 是 beginReplyTargetTurn 既有的结构(alias 写入 gate 在 replyRootId 上),要改动的是另一条独立契约;而且加上 inThread 之后,会命中抑制的只剩「顶层 @ 之后才被开成话题」这一种真正的目标场景,影响面比原来小很多。倾向留作单独的 follow-up,你如果认为该一起处理我再补。

验证(均为实际执行结果)

  • tsc --noEmit exit=0;bun run build 通过
  • 相关 21 套件 1300/1300 通过(含 event-dispatcher、session-store-sqlite、group-join-shared-routing、ordinary-turn-recovery、transfer-session、session-lifecycle-start、command-handler、prompt-builder)
  • 新增覆盖:
    • 判据本体四条 —— 顶层 @ 命中 / 原生话题 seed 不命中 / 老记录(无该字段)不命中 / 话题内回复不命中
    • 一条端到端 —— 真 beginReplyTargetTurn 落记录、喂给真判据,事后话题被平铺、用户真开的话题保持锚定
    • 持久层三态往返 —— false / true / 字段缺失写进真 SQLite store 再读回,判据结论一致(防「序列化时按 falsy 把 false 丢掉」)
    • 五条路径的接线钉桩 —— 钉「值来自 parsed.threadId」而不是只钉「字段存在」
  • 反向变异 8 处全部有牙:判据去掉 inThread 半位(3 失败)、判据恒真(3)、写入方不记该位(2)、写入方按 falsy 丢弃 false(2)、dispatcher guard 短路(2)、guard 恒真(7)、已有会话路径写死 false(1)、passthrough 丢弃透传(1)

值得单独说一句:「判据去掉 inThread 半位」这个变异,在改动前是 7 套件 650 测全绿。判据本体此前零覆盖,正是缺陷能藏进来的原因,所以这次特意把覆盖补在判据本体上而不只是补在 dispatcher 行为上。

飞书 live 实测仍未做(需要 build + 重启 daemon,会影响全部 bot),维护者确认部署方式后可以补。

deepcoldy and others added 2 commits August 28, 2026 07:06
普通群 chat/shared 模式下,用户先在群顶层 @ 机器人(机器人已按顶层平铺回复),
之后在**同一条消息上手动开启话题**。飞书随后把该话题内的后续消息投递成
root_id=<那条原顶层消息> + thread_id=<新建 omt_ 话题>。此时
maybeFoldMentionedRegularGroupThreadToChat 正确地把会话折叠回群 chat-scope,
但同时把 rootId 作为 replyRootId 返回,导致可见回复被 reply_in_thread 钉进
一个用户并未在其中 @ 过机器人的话题里。

daemon 日志可见这对自相矛盾的记录:
  [reply-mode] mentioned thread root=... folds into chat=...
  Replied ... replyInThread=true

修法不能只看 id 形状:既有用例(chat/shared fold)本身就是
root_id(om_) != thread_id(omt_) 却要求保留话题锚定,仅凭前缀无法区分两种场景。
改为以会话自身记录判定——该 root 是否曾被本 chat-scope 会话以顶层平铺方式
答过(turnReplyContexts[root].target.mode === 'plain')。命中即照旧折叠回群,
但不再设置 replyRootId;「@ 在既有话题里」永不命中,故原有话题锚定契约不变。

影响面:仅普通群 chat/shared 两种 reply-mode 的这一条 fold 路径;
话题群、chat-topic、new-topic、p2p 及各 CLI 适配器均不受影响
(新判据缺省 false,未接线时保持旧行为)。

验证:
- 复现测试修复前失败(expected 'om_earlier_top_level_at' to be undefined)、修复后通过
- test/event-dispatcher.test.ts 全量 301/301 通过(含 chat/shared fold 与 p2p 话题锚定既有契约)
- 相关回归 5 套件 124/124 通过(reply-target-fallback、reply-mode-command、
  session-group-birth-anchor、initial-user-turn-opening、message-quota-enforcement)
- 反向变异:将新 guard 短路为 `if (false && ...)` 后仅新增用例失败(1 failed / 300 passed),确认判据有牙且未被其它 guard 掩盖
- tsc --noEmit 全仓类型检查通过(exit 0,零输出)

Co-Authored-By: Claude <noreply@anthropic.com>
上一版判据只看 `turnReplyContexts[root].target.mode === 'plain'`,把「用户
真正开的原生话题」也算了进去:

- `chat` 模式下原生话题的**开场消息**形态是「有 thread_id、无 root_id」,
  `maybeFoldMentionedRegularGroupThreadToChat` 因缺 root_id 早退、
  `maybeApplySharedTopicSeed` 因模式/非@门早退 ⇒ 该轮 replyRootId 为
  undefined ⇒ 同样被记成 `mode='plain'`
- 此后该话题内的 @ 带 `root_id=开场消息 id`,命中判据 ⇒ 回复被平铺出一个
  用户确实在其中 @ 过机器人的话题
- 可达范围:`chat` 模式全部群聊 @ 策略;`shared` 模式配 `topic` 策略
  (该策略不在 shared-seed 的播种放行名单里,但 relax 的 topic 条款会放行)

`mode==='plain'` 只表示「答的时候没锚话题」,不表示「答的时候不在话题里」。
补上后者:

- `FrozenSessionReplyContext` 新增 `inThread`,由 `beginReplyTargetTurn`
  按 inbound 是否带 `thread_id` 如实记录(仅 chat scope;thread scope 走
  session.rootMessageId 不读该位)
- 判据改为 `mode==='plain' && inThread === false`,并从 daemon 私有函数提升为
  `core/reply-target.ts` 导出——与它唯一的写入方同处一处,避免两半漂移
- 老会话记录没有该字段:`undefined !== false`,按「未知」保持既有话题锚定
- `rehomeReplyTargetState` 不搬运该位:它描述的是源会话里 inbound 的形态,
  换目的地后不成立,落回「未知」是安全方向

五条 inbound 路径(初始 passthrough / 新话题 / 已有会话 / 自动建会话 /
passthrough 透传)逐条接入;入群首轮与定时任务无 inbound 消息,不写该位。

验证(均为实际执行结果):

- `tsc --noEmit` exit=0;`bun run build` 通过
- 相关 20 套件 1229/1229 通过(含 event-dispatcher、session-store-sqlite、
  group-join-shared-routing、ordinary-turn-recovery、transfer-session)
- 新增覆盖:判据本体四条(顶层命中 / 原生话题 seed 不命中 / 老记录不命中 /
  话题内回复不命中)+ 一条端到端(真记录喂真判据,事后话题被平铺、真话题
  保持锚定)+ 持久层三态往返(false/true/缺失读回后判据结论一致)+ 五条
  路径的接线钉桩
- 反向变异 8 处全部有牙:判据去掉 inThread 半位(3 失败)、判据恒真(3)、
  写入方不记该位(2)、写入方按 falsy 丢弃 false(2)、dispatcher guard 短路
  (2)、guard 恒真(7)、已有会话路径写死 false(1)、passthrough 丢弃透传(1)

其中「判据去掉 inThread 半位」这一变异在改动前是 7 套件 650 测全绿——判据
本体此前零覆盖,正是本次缺陷得以藏身的原因。
@deepcoldy
deepcoldy force-pushed the wt/botmux-claude-hi-3 branch from 93396f8 to 24da392 Compare August 28, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant