fix(lark): 顶层@后手动开话题不再把回复钉进事后话题 - #1023
Conversation
|
感谢这个修复 —— 问题定位和取舍都很到位,尤其是明确否掉了「按 id 形状( 下面是我复核时实测到的两点,想请你看一下(自动评审的初步意见,最终以维护者审阅为准): 1)建议合前修:判据在
|
|
补一条复核追加意见(同上,自动评审的初步意见,最终以维护者审阅为准)。第二轮独立复核确认了上一条的两点,并把误伤面又扩了一档: 误伤不止
|
ba129d7 to
93396f8
Compare
|
已 rebase 到最新 master 并把上面两条意见里的必修项直接实现了,推在同一分支上( rebase分支落后 master 79 个 commit(其间仓库做了 pnpm→bun 迁移、session-store 换 SQLite 引擎等)。rebase 后你原来的那条 commit 内容零变化,无冲突; 修法判据补上「答的时候在顶层」那半位,而不是只看「答的时候没锚话题」:
五条 inbound 路径(初始 passthrough / 新话题 / 已有会话 / 自动建会话 / passthrough 经 turn 结构体透传)逐条接入。入群首轮和定时任务没有 inbound 消息,不写这一位。 关于第 2 点(
|
普通群 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 测全绿——判据 本体此前零覆盖,正是本次缺陷得以藏身的原因。
93396f8 to
24da392
Compare
问题
普通群
chat/sharedreply-mode 下:root_id=<那条原顶层消息>+thread_id=<新建的 omt_ 话题>maybeFoldMentionedRegularGroupThreadToChat正确地把会话折叠回群 chat-scope,但同时把rootId作为replyRootId返回 → 可见回复被reply_in_thread钉进一个用户并未在其中 @ 过机器人的话题里用户预期:@ 机器人那一刻消息在顶层,回复就该留在顶层。
现场证据(daemon 日志中一对自相矛盾的记录)
会话已落到 chat-scope,可见回复却进了话题——决策与执行不一致。
为什么不能只看 id 形状
最初设想按
root_id是om_还是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 语义的猜测:
chatSessionAnsweredRootAtTopLevel(rootId, chatId, larkAppId):该 root 是否曾被本 chat-scope 会话以顶层平铺方式答过,即turnReplyContexts[root].target.mode === 'plain'(活跃会话与磁盘上 active 会话都查)EventHandlers注入 dispatcher;fold 命中该判据时:会话照旧折叠回群(scope='chat'不变),但不再设置replyRootId,回复平铺回顶层mode='thread'),因此原有话题锚定契约完整保留影响面
chat/shared两种 reply-mode 的这一条 fold 路径chat-topic、new-topic、p2p 各路径均不受影响false(未接线时保持旧行为),故不影响其它 CLI 适配器与会话类型验证
均为实际执行结果:
AssertionError: expected 'om_earlier_top_level_at' to be undefined;修复后通过test/event-dispatcher.test.ts301/301 通过(含 chat/shared fold 与 p2p 话题锚定既有契约用例)reply-target-fallback、reply-mode-command、session-group-birth-anchor、initial-user-turn-opening、message-quota-enforcementdaemon.ts):上述 6 套件合计 425/425 通过if (false && ...)→ 仅新增用例失败(1 failed / 300 passed),确认该判据真正生效且未被其它 guard 掩盖tsc --noEmit全仓类型检查通过(exit 0,零输出)待办
飞书 live 实测尚未进行(需 build + 重启 daemon,会影响全部 bot,待确认部署方式后补充结果)。实测方法:在普通群顶层 @ 机器人 → 等其平铺回复 → 在那条消息上手动开启话题后再 @ 一次 → 回复应落在顶层,不再进入话题。
🤖 Generated with Claude Code