Skip to content

feat: 替身模式增强——引用回复/群白名单/Owner控制卡片/头像解析 & 修复多触发回复锚点串扰#445

Merged
deepcoldy merged 23 commits into
deepcoldy:masterfrom
alexander2618:feat/substitute-resolver-and-avatar
Jul 17, 2026
Merged

feat: 替身模式增强——引用回复/群白名单/Owner控制卡片/头像解析 & 修复多触发回复锚点串扰#445
deepcoldy merged 23 commits into
deepcoldy:masterfrom
alexander2618:feat/substitute-resolver-and-avatar

Conversation

@alexander2618

@alexander2618 alexander2618 commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

概述

优化替身模式(substitute mode),新增回复方式选择、群聊白名单、owner 控制卡片、头像自动补全等能力,并修复了多替身触发时回复锚点串扰的问题。

主要变更

新功能

  1. 回复方式可选replyMode):替身触发后可选择「话题回复」(默认,在触发消息下创建 Lark 话题)或「引用回复」(引用触发消息,不创建新话题)。
  2. 群聊白名单chats):可指定替身模式仅在哪些群生效,留空则所有普通群均可触发。
  3. Owner 控制卡片:替身会话启动后,自动私信给 owner / allowedUsers 一张含「打开可操作终端、重启、关闭」按钮的控制卡片,便于随时接管。可通过 disableControlCard 关闭。
  4. 头像与名字自动补全:配置替身对象时,输入邮箱 / open_id 后失焦自动解析,显示对方头像和名字;解析失败标红并给出原因(跨应用 open_id / 解析失败 / 无法识别)。

修复与改进

  1. 修复多替身触发串消息:同一会话内多个替身触发时,每条触发消息现在有独立的回复锚点(replyRootId),不再共享/覆盖 currentReplyTarget,避免回复串到错误消息下或塌成 plain 发送。
  2. 替身会话关闭 streaming card:替身触发的会话不显示 live 状态卡片和 web-terminal 按钮(avatar 风格),改由 owner 控制卡片补偿。
  3. Footer 寻址调整:替身模式下回复的 @ 对象从 owner 改为触发替身的当前用户(isSubstitute)。
  4. 解析失败原因细化SubstituteTargetResolution 新增 reason 字段(cross_app_open_id / resolve_failed / unresolvable),手填的跨应用 open_id 现在会被拒绝,避免运行时静默失败。

附带修复

  1. oncall 退出释放自动绑定群setWorkingDirMode 退出 oncall 模式时,释放由 defaultOncall 自动绑定的群(回退到 bot 级默认目录),手动绑定的群保留。

向后兼容

  • 所有新增字段均为可选,旧配置不受影响。
  • normalizeSubstituteMode 会对非法值进行 coerce(如无效 replyMode 回退为 thread)。
  • 新字段(chats / replyMode / disableControlCard / avatarUrl)在未设置时不写入持久化配置。

测试

  • 新增 test/substitute-control-card.test.ts(控制卡片投递全场景)
  • 新增 test/substitute-mode-normalize.test.ts(配置归一化)
  • 补充 reply-anchor、footer-addressing、resolve、store、ipc、oncall-store、turn-reactions 等测试用例

sqma and others added 14 commits July 10, 2026 16:53
- 后端:save 时拒绝 cross-app open_id,返回 reason 便于 dashboard 提示
- 后端:SubstituteTarget 增加 avatarUrl 字段并持久化
- 后端/dashboard:新增 POST /api/bots/:appId/substitute-targets/resolve 预览接口
- dashboard:target ID 输入框 blur 时自动调用 Lark 解析并回填 name/avatar
- dashboard:解析成功后姓名只读,头像与姓名在同一排展示
- dashboard:解析失败时显示红色原因 chip(如 cross_app_open_id)
- i18n:补充 substitute 解析相关文案
- 测试:更新 substitute-target-resolve 测试
- gitignore:忽略 Playwright MCP 验证产物
@alexander2618
alexander2618 requested a review from deepcoldy as a code owner July 12, 2026 04:50
@deepcoldy deepcoldy changed the title Feat/substitute resolver and avatar feat: 替身模式增强——引用回复/群白名单/Owner控制卡片/头像解析 & 修复多触发回复锚点串扰 Jul 16, 2026

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review @ head 72bdde87

整体方向和实现质量不错:replyRootId 锚点修复思路正确且只作用于 substitute 分支、控制卡时序(worker-ready 消费在 card-off 早退之前)正确、normalize 向后兼容、测试覆盖比较全。本地验证:pnpm build 绿,PR 涉及的 14 个测试文件 381 个用例全绿(fork PR 无 CI,已本地跑)。

但当前不可合并:落后 master 130 个提交,7 个文件冲突(src/cli.tssrc/core/worker-pool.tssrc/daemon.tssrc/im/lark/event-dispatcher.ts + 3 个测试)。冲突全落在本 PR 的核心改动点上,且 master 侧 event-dispatcher 刚被 #494(群聊 @ 策略统一)改过,rebase 后语义需要重新核对,请 rebase 后我再做 delta review。

P2 — oncall autobound 释放会误伤手动绑定(附带修复 #9

setWorkingDirMode 退出 oncall 时按 defaultOncallAutoboundChats 成员释放 oncallChatssrc/services/oncall-store.ts)。但这个列表的既有语义是「auto-bind 一次性机会已用掉」的 tombstone:

  • unbindOncall 也会把手动解绑的群写进去(防止 auto-bind judge 重新绑定);
  • bindOncall(手动绑定)不会把群从里面移除。

所以它不是「当前由 auto-bind 绑定的群」清单。后果:群 X 曾被 unbind(或曾被 autobind 后释放)→ 用户手动 /oncall bind X 重新绑定 → 之后任何一次 dashboard 把工作目录模式切到 default/off → X 的手动绑定被静默释放。且释放逻辑不检查 defaultOncall 此前是否启用过(mode !== 'oncall' 即触发),触发面比预期宽。

建议:释放条件收紧为「prior?.enabled === truec.workingDir === prior.workingDir(即仍指向旧 oncall 目录)」,或单独维护一份「当前 autobound」列表(autobind 时加、手动 bind/unbind 时移除),不要复用 tombstone。

P3

  1. substituteTriggered 是会话级永久闩锁:chat-scope 会话全群共享。regularGroupReplyMode='chat' 的 bot 一旦发生替身触发,session.substituteTriggered=true 永不回落——之后同群普通 @bot 轮次也永远没有 streaming card / 终端按钮。且 daemon.ts:7457 的 fold-in 场景(普通会话后来被替身触发)只关卡、不补发控制卡(pendingSubstituteControlCard 只在建会话时设置,worker ready 已经过了)。每轮的替身身份其实已有记录(currentReplyTarget.substitute),建议卡片开关改按 per-turn 判定,或至少在 fold-in 时补发一次控制卡。
  2. cross_app_open_id 误报resolveSubstituteTargetsgetProfile 瞬时失败(网络/权限抖动,走 catch)时,手填 open_id 也被判成「属于其他应用」,文案误导;save 路径同理会把此前合法的 open_id target 丢掉(若是唯一 target 则整个保存 400)。建议 catch 分支报 resolve_failed,只有 profile 明确返回 null 才报 cross_app_open_id
  3. userId 目标的 resolve 预览 UX:userId passthrough 返回 ok:true 但无 name/avatar——用户若还没填名字就失焦,name 输入框会被一个空 chip 替换,无法再手填名字(改 id 才能重置);解析失败(ok:false)时 name 输入框同样消失。建议 chip/badge 只在有内容时替换输入框。
  4. resolve 双飞竞态resolveTargetRow 快速两次失焦会有两个 in-flight 请求,last-completion-wins,旧响应可能覆盖新响应;且函数开头从 render 快照 targetRows 读行数据。低危,建议加个序号守卫。
  5. i18n key botDefaults.substituteResolved 新增但未被使用。
  6. 行为变化确认:旧版顶层替身触发的回复是 plain + 引用链;新默认 'thread' 会为每次触发在群里开一条话题。对存量替身用户是可见变化(PR 描述已声明)——确认这是有意的默认值即可。
  7. (非本 PR 引入,记录一下)并发双触发时 currentReplyTarget / quoteTargetId 仍是会话单值 last-writer-wins:老 turn 回落 plain 后,quote-chain 可能引到新触发消息(quoteTargetId 无 turn 守卫)。本 PR 已显著改善常见路径,残留属既有机制。

核过没问题的

  • 控制卡 audience = resolvedAllowedUsers(ou_ only),与既有私发 /card 口径一致;token 不落日志。
  • quote 模式下 audio / 文件 / 普通 dispatch 三条路径的 replyInThread 参数都传对了。
  • 白名单 gate 顺序不影响未配置替身的 bot 热路径。
  • defaultOncallAutoboundChats tombstone 在测试里保持不清空(防重新 autobind)与既有语义一致。

结论

需要先 rebase 解冲突 + 修 P2,P3 里 1/2 建议一并处理。改完请求 delta review 即可,不用整轮重来。

7 个冲突文件的解法:
- cli.ts: 保留 master 的 dispatch/voice 重构(uuid/suppressHook/VC managed send),
  在新结构上重新应用 replyTargetQuoteOnly 透传 + replyInThread 按 mode 判定
- worker-pool.ts: buildWritableTerminalCard 保留 export(控制卡复用)+ master 的
  riff 后端分支;ready handler 中控制卡消费保持在所有 early break 之前
- daemon.ts: sessionReply quote 分支改走 master 新增的 replyWithHookPolicy;
  substituteTriggered 标记与 codexAppMessageContext、vcMeetingReceiver 块共存
- event-dispatcher.ts: PR 的 replyRootId 独立锚点 + master 的日志防注入
  (configuredTargetForLog) 并存
- 3 个测试文件: PR 与 master 各自新增的测试全部保留;cli-send-hook-context
  的正则按合并后代码更新(sendTarget.mode === 'thread' + uuid 参数)
master 的 dashboard-rebase-ui-regressions 守门测试禁止自定义下拉嵌在 label 内
(点击标题会误激活第一个选项),与相邻 disclosure 下拉对齐改用 bd-field div。
@deepcoldy

Copy link
Copy Markdown
Owner

已代为解决与 master 的冲突并推送(72bdde87..32dd1074,maintainer 权限推到本分支),PR 已恢复 MERGEABLE。共 3 个提交:

  1. 1901eac8 merge origin/master(解 7 文件 15 处冲突),关键解法:
    • cli.ts:保留 master 的 dispatch/voice 重构(uuid / suppressHook / VC managed send),在新结构上重新应用 replyTargetQuoteOnly 透传 + replyInThreadmode === 'thread' 判定
    • worker-pool.tsbuildWritableTerminalCard 保留 export + master 新增的 riff 后端分支;ready handler 中控制卡消费保持在所有 early break(含 master 新增的 managedAuxUiSuppressed)之前,card-off 的替身会话仍能发出控制卡
    • daemon.ts:sessionReply 的 quote 分支改走 master 新增的 replyWithHookPolicy(带 uuid/hook 策略);substituteTriggered 标记与 codexAppMessageContext、vcMeetingReceiver 块共存
    • event-dispatcher.ts:本 PR 的 replyRootId 独立锚点与 master 的日志防注入(configuredTargetForLog)并存
    • 测试:双方各自新增的测试全部保留(event-dispatcher 里 git 把两个不同新测试错位穿插,已手工拆开重组);PR 的锚点测试补上了 master 新增的 observedMention 字段断言
  2. 72b3fc19 二次 merge:第一次解冲突期间 master 又推进(feat(dashboard): 增加多 Bot 话题聚合视图 #499feat(riff): 配置面板精简——Agent 默认 codex、移除沙箱集群与注入状态行选项 #501),二次合并无冲突
  3. 32dd1074 集成修复:master 的 dashboard-rebase-ui-regressions 守门测试禁止自定义下拉嵌套在 <label> 内(点击标题会误激活第一个选项)——本 PR 新增的 replyMode 下拉恰好踩中,已按相邻 disclosure 下拉的 bd-field 规范对齐

验证(fork PR 无 CI,全部本地跑):

  • pnpm build 绿
  • PR 涉及的 16 个测试文件 448/448 全绿(含 master 移动过的 riff-backend / dashboard-sidebar-create-actions)
  • 全量套件 9327 passed / 18 failed,18 个失败逐一归因均为本机已知基线(coco e2e×7、multi-bot-session×2、v3-distillation×6、时区敏感 scheduler×3),16 个 suite 级失败为 e2e-browser 缺 FEISHU_TEST_GROUP_URL 等环境变量,与本 PR 无关

⚠️ 注意:只解了冲突,之前 review 里的 P2(oncall autobound tombstone 误释放手动绑定)和 P3 仍待处理

- P2 oncall-store: 退出 oncall 释放 autobound 群的条件收紧为「defaultOncall 此前
  确实启用 && 群仍指向旧 oncall 目录」。defaultOncallAutoboundChats 是 tombstone
  (手动 unbind 也写入、手动 bind 不清除),仅凭成员资格无法区分「当前 autobound」
  与「手动重绑」,原实现会把手动重绑的群静默释放
- P3 卡片开关: 去掉 session.substituteTriggered 会话级永久闩锁,streaming card
  的替身抑制改按当前轮 currentReplyTarget.substitute 判定——chat-scope 会话全群
  共享,闩锁会让替身触发一次后同群普通 @bot 轮次永远无卡
- P3 resolve: getProfile 抛错(网络/限流瞬时失败)时手填 open_id 报 resolve_failed
  而非 cross_app_open_id,避免误导用户丢弃合法 target;仅确定性 null 才算跨应用
- P3 dashboard: userId 直填(无 openId 可验证)不再显示空「已解析」chip、保留可
  编辑的名字输入框;resolve 双飞加 per-row epoch 守卫防旧响应覆盖新结果;
  移除未使用的 i18n key substituteResolved
@deepcoldy

Copy link
Copy Markdown
Owner

Review 的 P2 与主要 P3 已修复并推送(32dd1074..9f0da998),逐项:

P2 — oncall autobound 释放误伤手动绑定src/services/oncall-store.ts
释放条件收紧为「prior.enabled === true(此前确实处于 oncall 模式)&& c.workingDir === prior.workingDir(群仍指向旧 oncall 目录)」。defaultOncallAutoboundChats 是 tombstone(手动 unbind 也写入、手动 bind 不清除),仅凭成员资格无法区分「当前 autobound」与「手动重绑」。新增 2 个回归测试:手动重绑到其它目录不释放;defaultOncall 从未启用时任何模式切换不碰绑定。已知残留边界(保守方向):oncall 目录中途换过的旧 autobind、或手动重绑到与 oncall 相同目录的群,按目录匹配判定——宁可漏放不误删。

P3① — substituteTriggered 会话级永久闩锁 → 按轮判定daemon.ts / worker-pool.ts / types.ts
删除 session.substituteTriggered 字段及全部写点(含 fold-in 标记块),streaming card 的替身抑制改读 (ds.currentReplyTarget ?? ds.session.currentReplyTarget)?.substitute === true。效果:替身建的会话每轮都是替身轮,行为与原意完全一致(永远无卡);混合会话(reply-mode='chat' 群里替身与直接 @bot 共享会话)在普通 @bot 轮次恢复卡片,不再被一次替身触发永久拉闸。已核对全部 6 个 gate 调用点(ready 卡片、scheduleCardPatch、screen-update、LocalCliOpen/RiffUrl 按钮刷新、turn-reactions):替身轮冻结普通轮卡片 patch 的混合并发边界与原闩锁版一致或更好。turn-reactions 测试改为 per-turn 语义并新增「后续普通轮恢复卡片(无闩锁)」用例。

P3② — cross_app_open_id 误报substitute-mode-store.ts
getProfile 抛错(网络/限流瞬时失败)时手填 open_id 现在报 resolve_failed,仅确定性 null(本应用看不到该 open_id)才报 cross_app_open_id,不再误导用户丢弃合法 target。新增测试。

P3③④⑤ — dashboard 细节bot-defaults-page.tsx / i18n.ts
userId 直填(无 openId 可验证)不再显示空「已解析」chip,保留可编辑的名字输入框;resolve 请求加 per-row epoch 守卫,双飞时旧响应不再覆盖新结果;移除未使用的 i18n key substituteResolved

验证pnpm build 绿;涉及的 15 个测试文件 422/422 + i18n 键一致性 434/434 全绿;全量套件 9331 passed / 18 failed,18 个失败与本机已知基线(v3-distillation×6、coco e2e×7、multi-bot×2、时区 scheduler×3)严丝合缝,零新增。

未处理(初审中已注明):P3 残留并发限制(quoteTargetId 无 turn 守卫,PR 前已存在);替身默认 replyMode 从旧「plain+引用链」变为「thread」是 PR 声明的有意行为变化。

…、bind 清 tombstone

- P2#1 单槽锚点: Session 新增 replyTargets(turnId → {rootMessageId,quoteOnly,
  substitute},cap 32 淘汰最旧)。排队/并发轮次(如双替身触发)各自保留锚点:
  daemon resolveSessionReplyTarget 与 botmux send(pickTurnReplyTarget)按
  turnId 精确查 map,miss 再退回单槽 currentReplyTarget(旧会话数据兼容)。
  卡片 gate 同根因修复:streamingCardDisabled(For) 增加 turnId 参数走
  isSubstituteTurn 精确判定,screen_update 传 msg.turnId、turn 反应传
  triggerMessageId——A 替身轮执行中 B 普通轮入队不再互相误开/误关卡片。
  字段仿 docCommentTargets 只挂 session(updateSession 既有链路持久化),
  A/B 两种到达顺序的路由与 gate 测试各两组
- P2#2 生产可达的瞬时/确定性区分: client.ts 新增 getUserProfileStrict 三态
  (ok/not_visible/error),确定性码集合 {41050,41012,40001,99992361}(后者
  即现有 allowedUsers 诊断用的「open_id 属它应用」码);瞬时错误不再写负缓存
  (原实现连网络抖动都缓存 null)。resolveAllowedUsersWithMap 新增 errored
  信号(内部吞错后唯一生产可达的瞬时标志)。substitute deps 契约改三态,
  dashboard-ipc 注入 strict 版
- P2#3 同目录手动重绑: bindOncall(显式 opt-in)清除 tombstone,release 的
  provenance 判定不再把手动重绑当 autobind;dir-match 双条件保留兜底旧数据
@deepcoldy

Copy link
Copy Markdown
Owner

codex 二审的 3 个 P2 已全部修复并推送(9f0da998..b2635086,修复主体在 cc7a8ab2),逐项:

P2#1 — 单槽锚点/gate → per-turn(核心修复)
Session 新增 replyTargets: Record<turnId, { rootMessageId, updatedAt, quoteOnly?, substitute? }>beginReplyTargetTurn 双写;仿 docCommentTargets 只挂 session、走既有 updateSession 链路持久化;cap 32 按 updatedAt 淘汰最旧,被淘汰的轮次退回原单槽行为)。

  • 路由:daemon resolveSessionReplyTargetbotmux send(新 helper pickTurnReplyTarget,voice/主 dispatch/footer 三处)按 turnId 精确查 map,miss 再退回单槽——codex 复现的「A 排队期间 B 覆盖 → A 塌成 plain」不再发生,A/B 各回各的锚点,per-turn quoteOnly 也随之精确。
  • 卡片 gate:streamingCardDisabled(For)turnId? 参数走 isSubstituteTurn(map 精确 → 单槽 fallback);screen_updatemsg.turnId、turn 反应传 triggerMessageId。已核对 begin(map 写入)在所有 noteTurnReceived 调用点之前,无先读后写窗口。
  • 测试:reply-target 单元(双顺序+quote 变体+cap 淘汰回退)、真 sessionReply 链路(双顺序)、卡片 gate(双顺序)共 6 组新用例。
  • 诚实边界:无 turn 上下文的 gate 调用点(worker ready、按钮刷新、frozen-card 路径)仍用最新单槽 fallback——这些点本身没有 turnId 可用,行为不劣于修复前;quoteTargetId 引用链单槽为 PR 前已存在问题,未动。

P2#2 — 生产可达的瞬时/确定性区分

  • client.ts 新增 getUserProfileStrict 三态(ok / not_visible / error)。确定性码集合 = {41050 无权限见该用户, 41012 user id 无效, 40001 参数无效, 99992361 open_id 属其他应用}(最后一个就是现有 allowedUsers 诊断在用的码;41050/41012/40001 对照官方 contact/v3/users GET 文档核过)。
  • 负缓存收紧:仅 ok/not_visible 写缓存,瞬时错误不缓存——原实现连网络抖动都缓存 null,重试永远命中负缓存,这个也一并修了(getUserProfile 其它调用方同收益,契约不变)。
  • resolveAllowedUsersWithMap 新增 errored 返回位(内部吞错后唯一生产可达的瞬时信号;确定性码不置位),substitute 的 email/union 路径据此报 resolve_failed vs unresolvable。deps 契约改三态,dashboard-ipc 注入 strict 版。测试覆盖 email errored、openId transient、email-resolved target 在 profile 瞬时失败时保留。

P2#3 — 手动重绑同目录仍被释放
bindOncall(显式 opt-in)现在清除 tombstone:provenance 判定不再把手动重绑当 autobind,同目录重绑也安全;后续 unbind 会照旧重新写 tombstone,auto-bind judge 的 already_bound 检查保证 bound 期间语义不变。prior.enabled && dir==prior.workingDir 双条件保留,兜底本修复部署前已持久化的旧状态。补同目录重绑回归测试。

验证pnpm build 绿;涉及 17 个测试文件 868/868;全量 9342 passed / 19 failed——18 个与本机基线(v3-distillation×6、coco×7、multi-bot×2、时区 scheduler×3)严丝合缝,1 个 agy-input.e2e.ts 为 PTY 时序 flake(单跑 6/6 绿),零新增。期间 master 又前进(#391/#419/#448 等),已零冲突合入并在合并树上复验 build+868 用例。

@alexander2618 供参考:以上改动均为 review 修复,功能语义与你的原设计一致(替身会话行为不变,修的是排队/并发轮次的串扰)。

…profile 接住 SDK 抛错并按原因分级

- P2-1 card gate 三个漏口:
  1) isSubstituteTurn 有 turnId 时 map miss 只在单槽 turnId 相符时才 fallback,
     否则 false——rootless 普通轮(无 replyRootId 不留 map 记录)不再继承后到
     替身轮的 slot 标志(codex 复现 normalAIsSubstitute:true 已修)
  2) ready 消息本就携带 turnId,gate 改传 msg.turnId——替身 A 在 ready 前被
     普通 B 覆盖 slot 不再误发初始卡
  3) scheduleCardPatch 加可选 turnId,screen_update 路径透传——PATCH 二次 gate
     不再退回单槽(screenshot_uploaded 消息无 turnId,维持 fallback)
  测试: 真实 begin(undefined,A)→begin(B,sub) 双顺序 + isSubstituteTurn 无 turn
  fallback + scheduleCardPatch 双顺序 gate
- P2-2 strict profile:
  1) catch 分支用 getLarkErrorCode 提取业务码——SDK(Axios)非 2xx 以异常抛出,
     403+99992361 等确定性失败不再误判 transient
  2) 按原因分级: cross_app(99992361)/not_visible(41050)/invalid_id(41012,40001)
     /error(其余+网络),负缓存按类别存;substitute reason 相应映射
     cross_app_open_id/not_visible(新 i18n)/unresolvable/resolve_failed,
     「通讯录不可见」不再谎称「属于其他应用」
  3) 新增 lark-user-profile-strict.test.ts: Axios throw 形态驱动真实实现
     (cross_app/not_visible/invalid_id/transient 不缓存共 7 例)
@deepcoldy

Copy link
Copy Markdown
Owner

delta review 的 2 个 P2 已修复并推送(b2635086..bd020a7d),逐项:

P2-1 — card gate 三个漏口全部补上

  1. isSubstituteTurn(reply-target.ts):有 turnId 时 map miss 只在 slot.turnId === turnId 时才 fallback,否则返回 false——rootless 普通轮(无 replyRootId、不留 map 记录、begin 时清 slot)不再继承后到替身轮的 slot 标志。用真实 begin(undefined, A) → begin(B, substitute) 序列复现(codex 的 normalAIsSubstitute:true),双顺序 + 无 turn 上下文 fallback 共 3 组新测试。
  2. ready gate 改传 msg.turnId(协议确实携带,worker.ts:8056 turnId: currentBotmuxTurnId)——替身 A 在 ready 前被普通 B 覆盖 slot 不再误发初始卡。替身根轮总有 replyRootId → map 精确命中;恢复场景 turnId 缺失时退回 slot(保持 avatar 掩护)。
  3. scheduleCardPatch 增加可选 turnId,screen_update 路径透传 msg.turnId——PATCH 的二次 gate 不再退回单槽。screenshot_uploaded 消息本身不带 turnId(worker.ts:3505),维持 fallback(诚实边界)。scheduleCardPatch 双顺序 gate 测试 2 组(cardPatchInFlight 隔离到只测 gate)。

P2-2 — strict profile 接住 SDK 抛错 + 按原因分级

  1. catch 分支先 getLarkErrorCode(err) 提业务码(仓库现成 helper,err.response.data.code)——Axios 非 2xx 抛出的 403+99992361 等确定性失败不再误判 transient。
  2. 分级从三态扩到五态:cross_app(99992361) / not_visible(41050) / invalid_id(41012,40001) / error(其余码+网络) / ok;负缓存按类别存、transient 仍不缓存。substitute reason 映射:cross_app→cross_app_open_id、not_visible→新 reason not_visible(新增 zh/en 文案「不在应用通讯录可见范围,请调整应用可见性或改用邮箱」)、invalid_id→unresolvable、error→resolve_failed——「通讯录不可见」不再谎称「属于其他应用」。union 解析的 errored 判定同步走 classify(含 catch 提码)。
  3. 新增 test/lark-user-profile-strict.test.ts:mock SDK Client.request、驱动真实 getUserProfileStrict——Axios throw 形态的 cross_app(含缓存断言)/ not_visible / invalid_id×2 / transient 三形态不缓存(第 4 次调用重新打 API 并成功)/ code-0 无 user / legacy getUserProfile 契约,共 7 例。

验证pnpm build 绿;18 个相关测试文件 881/881;全量 9487 passed / 20 failed——18 个与基线严丝合缝,另 2 个(agy-input / codex-input)为真实 CLI PTY 输入时序 e2e:两者只 import node-pty/IdleDetector/各自 adapter,与本 diff 零共享代码路径;纯 master worktree 对照 4/4 绿、本分支单独连跑 3/3 绿,失败仅出现在全量套件高负载期间且每次挂的用例不同——负载相关 flake,非本 PR 引入。

@codex 请做第三轮 delta(b2635086..bd020a7d)。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

两个替身特性做并集,语义组合点:
- SubstituteModeConfig/dashboard/PUT: deepcoldy#475 topicGroups+topicActiveSessionTrigger
  与 deepcoldy#445 chats/replyMode/disableControlCard 全保留
- event-dispatcher 触发门: chats 白名单前置到 getChatMode 之前(纯内存判断走在
  API roundtrip 前),对普通群与话题群统一生效;group 分支保留 deepcoldy#445 的独立
  replyRootId 锚点,topic 分支维持 deepcoldy#475 的 thread-scope 路由
- owner 控制卡收窄为仅 chat-scope 替身会话发送: 控制卡是为 card-off(avatar)
  的群会话补偿的;deepcoldy#475 话题替身会话是 thread-scope、保留正常 streaming card,
  不需要也不该多收一张 DM
- 测试: git 把 deepcoldy#445 白名单测试与 deepcoldy#475 六个话题群测试错位穿插,按两侧原文
  拆开重组,15 个 substituteMode 用例全绿
@deepcoldy

Copy link
Copy Markdown
Owner

master 合入 #475(替身模式支持话题群)后的语义冲突已解决并推送(bd020a7d..1a4c4cf6,单 merge commit),PR 恢复 MERGEABLE。6 个冲突文件全部为两特性求并集,其中三个语义组合点需要 reviewer 关注:

  1. 触发门组合(event-dispatcher):chats 白名单前置到 getChatMode 之前(纯内存判断走在 API roundtrip 前,保持 feat: 替身模式增强——引用回复/群白名单/Owner控制卡片/头像解析 & 修复多触发回复锚点串扰 #445 原有热路径考量),并对普通群与话题群统一生效——白名单语义是「替身可触发的群」清单,与群形态无关。group 分支保留 feat: 替身模式增强——引用回复/群白名单/Owner控制卡片/头像解析 & 修复多触发回复锚点串扰 #445 的独立 replyRootId 锚点;topic 分支维持 feat(lark): 替身模式支持话题群——@替身对象沿话题会话代答 #475 的 thread-scope 路由与让路逻辑,不加 replyRootId(回复天然落回本话题)。
  2. 控制卡收窄为仅 chat-scope 替身会话发送(daemon 两处 twin):控制卡是给 card-off(avatar 风格)的群 chat-scope 会话补偿的;feat(lark): 替身模式支持话题群——@替身对象沿话题会话代答 #475 话题替身会话是 thread-scope、保留正常 streaming card,不需要也不该让 owner 多收一张终端 DM。
  3. 配置面/dashboardtopicGroups/topicActiveSessionTriggerchats/replyMode/disableControlCard 并存,save/回显/normalize 全量透传;测试文件里 git 把 feat: 替身模式增强——引用回复/群白名单/Owner控制卡片/头像解析 & 修复多触发回复锚点串扰 #445 白名单测试与 feat(lark): 替身模式支持话题群——@替身对象沿话题会话代答 #475 六个话题群测试错位穿插成一个冲突块,按两侧原文拆开重组(15 个 substituteMode 用例全绿)。

验证pnpm build 绿;合并影响面 9 文件 758/758 + touched 批 412/412;全量 9498 passed / 18 failed,与本机基线严丝合缝(v3-distillation×6、coco×7、multi-bot×2、时区×3),本轮零 flake 零新增

@codex 请审这段 merge delta(bd020a7d..1a4c4cf6)出最终 merge-ready 结论。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

deepcoldy#475 合入后 chats 白名单对话题群也生效、控制卡仅普通群发、replyMode 仅
普通群路径生效——zh/en 六条帮助文案同步精确化,纯文本无逻辑改动。
@deepcoldy

Copy link
Copy Markdown
Owner

codex 终审的 P3 文案项已顺手修掉(1a4c4cf6..e7e4c5f8,纯 i18n 文本 6 条,zh/en 各 3:白名单说明覆盖话题群、控制卡注明仅普通群发、replyMode 注明话题群不受影响)。build 绿 + i18n 一致性/守门 441/441。最终 head e7e4c5f8,可合并。

@deepcoldy
deepcoldy merged commit 7a3e63d into deepcoldy:master Jul 17, 2026
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.

2 participants