Skip to content

fix(codex): 桥接异步提问到飞书卡片并回传原会话 - #1878

Open
uestcwangci wants to merge 6 commits into
deepcoldy:masterfrom
uestcwangci:fix/codex-async-user-input
Open

uestcwangci wants to merge 6 commits into
deepcoldy:masterfrom
uestcwangci:fix/codex-async-user-input

Conversation

@uestcwangci

@uestcwangci uestcwangci commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Codex App 的 request_user_input_async 通过 agentMessage(delivery: async)发出,原桥接只处理阻塞式 item/tool/requestUserInput,因此工具调用成功却没有飞书提问卡。本 PR 将异步问题桥接到独立 Ask 卡片,把答案按原 call ID / 题号回传同一原生线程,当前模型轮次继续工作。

最新修订修复真人异步卡的权限收窄:注册真人卡使用与 broker 点击同源的 evaluateAskAnswerTalk(larkAppId, chatId, sender, chatType),发起人只有 canTalk 权限也可收到卡。answererOpenId 仍注册时冻结为发起人本人;定时任务创建者及自治 owner 的 canOperate 来源检查保留。同步中英文档答复范围,删除 runner 重复的 itemText.clear() 并对齐相关缩进。

本次修订处理评审中的身份、协议兼容、并发卡片和可见失败问题:

  • 注册时冻结身份:从发起轮的冻结 reply target 取真人身份并锁定答题人;投递不再依赖可变的 lastCallerOpenId 或可能淘汰的 replyTargets。worker / generation / native thread 的换代检查保留。
  • 无真人轮次:从 host schedule authority 校验任务及 bot/chat 绑定、创建者授权;无创建者的合法旧任务可使用已授权 session owner。正常结束的一次性任务不要求 turn 仍 live。其他自治来源使用已授权 owner。此类卡片不硬锁创建者,沿用 canTalk 代答;按实际答题人构造 trustedCaller,保留 bot/user 归因。silent 定时任务不发卡,不打破完全静默。身份不可用时有可见提示。
  • 原生问题兼容:允许单选项、超过三题和重复标签;选项以 index key 区分,回传原标签。≥2 选项的旧阻塞式 Ask 校验保持不变。无隐式自动提交。
  • 多卡文字消歧:新增 listPendingAsksByAnchor,原 first-match API 不变。多卡并存时必须精确匹配 parentId === cardMessageId;无引用或引用无关消息继续普通输入并显示提示。thread-scope / chat-scope 均覆盖,文字题仍可引用卡片作答。
  • 失败可见与输出隔离:待答上限、非法问题、超时及答案未送达有用户可见提示;缺少 started 的 delta 先缓冲,按 item ID 剔除异步内容,避免误删同文本的普通输出;非法事件不永久占用去重 ID。答复中的 question 按原生规则规范化换行并截断为 512 个 Unicode 字符。
  • 编译态升级:二进制内嵌 runtime build ID,持久化 runner 沿既有空闲刷新机制识别升级;构建说明及资产审计注释同步更新。

影响共享 Ask IPC / broker / renderer、Codex App runner 和构建身份;同步提问、Claude/OpenCode hooks 保持旧路径。题组仍按一张卡原子提交,含文字题时回复整个题组,尚不支持 TUI 的逐题部分提交。待答卡仍不跨 daemon/runner 重启恢复,不补发历史问题。

验证:

  • 本轮完整 bun run build 通过;7 文件 / 415 项相关回归通过,覆盖 runner、Ask API/broker/card 和 talk parity;最终 daemon 路由 + async helper 150 项通过。

  • 新增 5 条真实 HTTP 路由测试:限制态 bot 仅 owner 有 operate、群授权放行 talk-only 发起人,在 thread/chat 两 scope 发卡并由该发起人点击回传同一原生线程;另一名可对话 owner 无法代答;未授权及黑名单用户维持 409 + 可见提示;定时创建者只有 canTalk 仍拒绝发卡。broker 注入生产 evaluateAskAnswerTalk,不模拟该权限判定。

  • 新测试在旧实现上两个 talk-only 场景失败;变异验证分别删除真人 talk 门、改回 operate 门、放宽定时创建者门,三个错误版本均被明确断言拒绝。

  • 完整 bun run build 通过,包含源码 / 脚本 / mock 类型检查和产物审计。

  • 真实 daemon HTTP 接线 12 个专门场景通过:A 发起后 B 插话、>32 轮来源淘汰、非共享定时与旧任务 owner 回退、创建者撤权、silent、bot/user 归因、身份缺失、超时、换代,以及两种 scope 的多卡引用 / 普通输入 / 按钮答复。测试运行生产 HMAC IPC server、生产 route、broker 和 worker accepted ledger;外部 IM、worker IPC 接收端及任务记录查询使用替身。

  • 原生 runner 完整回归与 helper 曾通过 114 项;新增待答上限等异步场景单独 13 项通过,非法通知修正和相同普通输出隔离再补 2 项通过。

  • 共享 Ask 与其他 CLI hooks 的 11 文件 / 352 项回归,以及Ask 接线相关 5 文件 / 286 项回归通过。

  • 与 feat(codex): 支持 /queue 将消息排队为独立轮次 #1880 合并的实际部署候选,19 文件 / 978 项回归通过;最后两项新增 runner 用例单独通过。编译版与本地部署验证在下方记录。

  • git diff --check 通过。未将模拟飞书 API 表述为全部场景的真实飞书人工验收。

可复现的关键命令:

bun run build
node node_modules/vitest/vitest.mjs run --project unit \
  test/daemon-rename-route.test.ts --testNamePattern 'async Ask production' --retry 0
node node_modules/vitest/vitest.mjs run --project unit \
  test/codex-app-runner.integration.test.ts test/codex-async-user-input.test.ts
node node_modules/vitest/vitest.mjs run --project unit \
  test/ask-api.test.ts test/ask-broker.test.ts test/ask-card.test.ts \
  test/ask-hook-claude.test.ts test/ask-hook-opencode.test.ts

卡片桥接示意(合成内容)

编译部署候选 3.41.0+async-queue.2 的 Bun / Linux x64 编译 runner 测试 40 项通过(13 helper + 27 runner),含两项原生输入 FIFO、所有异步场景和既有同步提问。

本地已部署合并版 3.41.0+async-queue.2:9 个 bot 与 Dashboard 在线(HTTP 200),配置 SHA 未变,9 个会话数据库的 779 条记录及逐库计数完整保留,11 个数据库备份校验通过。真实飞书新增边界场景尚未人工验收。

以上 .2 本地部署记录对应此前合并版本;本轮真人 canTalk 补丁已更新至本 PR,尚未重新部署。

@deepcoldy

Copy link
Copy Markdown
Owner

你好,这个 PR 的自动评审群已建好(飞书群,机器人双评审流程)。系统按名单自动邀请时受跨租户可见性限制没能直接拉你进群。

请点击下面的链接加入评审群(一年有效,群外成员需要通过此链接申请加入):
https://applink.larkoffice.com/client/chat/chatter/add_by_link?link_token=8eejdce3-6841-48d9-bf93-579793f15135

如果链接打不开,也可以把你的 GitHub 账号和飞书信息补录到名单文档,后续复审会自动拉你进群:https://bytedance.larkoffice.com/wiki/WJ1nwWbtxi89erkNGNbcgkt9nUe

(本条由 Botmux 自动巡检流程发布)

@deepcoldy

Copy link
Copy Markdown
Owner

更新:已通过内部流程把你直接邀请进评审群,无需再点上面的加群链接,在飞书里查收群邀请即可。(自动流程补拉,抱歉打扰)

@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见,最终以维护者审阅为准。

整体质量很好:线协议字段(questionItemId = ["request_user_input_async", itemId, index]、send_user_message_question_reply 信封、untagged 多/单元素数组)与 Codex 0.162 源码逐字对得上,delivery: async 文本剔除、答复权限定与投递前复核、XML 分隔符转义、编译指纹内嵌都考虑到了,CI 全绿。以下两条建议修改后再合:

P2-1:答复人身份绑定了可变的 session.lastCallerOpenId,并发下会静默丢答

daemon.ts 注册异步卡时把 session.lastCallerOpenId 冻结为 answererOpenId,投递前 isCurrent() 又实时比较一次该字段;而 daemon.ts:25631 表明,话题内每一条入站消息(在任何 admission 分支之前)都会无条件改写 lastCallerOpenId。

可复现时序:

  1. A 在话题发起轮次,模型发出异步提问,卡片注册(answerer = A),模型轮次正常结束、卡片仍可作答(本 PR 的设计意图);
  2. B 在同一话题发了任意一句话 → lastCallerOpenId 变为 B;
  3. A 点击卡片作答:broker 侧 answererOpenId 校验通过、卡片显示"已作答",但 isCurrent() 因 lastCallerOpenId(B) !== asyncCaller(A) 失败 → 409 async_answer_not_delivered,答案永远不进 Codex,且飞书侧没有任何提示。

反向场景:自治/定时轮没有新入站时,lastCallerOpenId 是更早的历史发言人甚至为空,空值会在注册前直接 409 async_question_origin_unavailable,连卡都不发。

建议:发起轮 caller 应取该轮冻结的身份,而不是可变 session 字段。runner POST 已携带 originTurnId(owner 的 replyTurnId / clientUserMessageId),daemon 可经 pickTurnReplyTarget(session, body.originTurnId)?.senderOpenId(或 managedTurnOrigin)取本轮发起人,注册时快照;isCurrent() 去掉 lastCallerOpenId 实时比较即可——越权防护已由 broker 的 answererOpenId(冻结快照)和 deliver 回调内的 by !== asyncCaller 两层提供,worker/generation/thread 比较防换代仍保留。另外建议补一条真实 daemon 接线的回归(现有集成测试的 HTTP 服务是测试桩,绕开了 isCurrent/lastCallerOpenId 实际接线),覆盖"卡存活期间他人插话后发起人仍能作答"。

P2-2:解析器比 0.162 原生 schema 更严,合法问题被整批静默丢弃

对照 0.162 core/src/tools/handlers/request_user_input_async.rs 与协议测试套件,服务端只拒绝三种输入:questions 为空、title 全空白、options 为空数组;具体差异:

  • 原生 options.min_items = Some(1),单选项合法,且工具描述明确引导 "Put the recommended answer first; the first option is preselected by default";当前复用阻塞式解析的 options.length < 2 规则,单选项整批被拒(runner 仅写日志、不发卡,模型继续等一个永远不来的答复,用户无感知);
  • 原生 questions 只有 min_items = 1、无上限,当前自加的 questions.length > 3 会丢弃 4 题以上的合法批次;
  • 原生 options: Vec<String> 不校验重复,当前重复 label 也整批拒绝(这条可商榷,但同样不应静默)。

实测当前行为:1 option → REJECT、4 questions → REJECT、duplicate labels → REJECT。建议放开单选项与题数上限(卡片渲染本身不要求 ≥2 个选项,单选项渲染为一个可提交按钮即可;保留现有"必须主动提交、不自动预选提交"的语义,这与原生 "A preselected option is not submitted automatically" 一致),重复 label 可按 index 去重或降级,并让无法发卡的批次对用户可见而非仅 runner 日志。

P3(备查,不阻塞)

  • 一批多问必须一次全部作答,任一题留空整批发不出(TUI 支持逐题提交);
  • codex-app-runner.ts 中 turn.allAgentText.replace(streamed, '') 只替换首次出现,相同文本早前存在时会误删普通输出;且 item/started 缺失时异步 delta 会先显示进终端;
  • seenAsyncQuestions.add 发生在 parseCodexAsyncQuestions 之前,解析失败也会在进程生命周期内永久标记该 item;
  • 答复信封中 question 字段未按原生 AnsweredQuestion::new 做 512 字符截断与换行/回车 → 空格规范化(不影响 questionItemId 关联,仅模型所见上下文微差);
  • 32 卡上限触达、投递 409 均无用户可见提示;audit-embedded-assets.mjs 关于 .runtime-build-id 的注释在本 PR 后已过时,且 build-bun-binary.mjs 现硬依赖先执行 bun run build,建议注释/README 对齐。

@deepcoldy

Copy link
Copy Markdown
Owner

补充一条落地细节(接上条自动评审意见,最终仍以维护者审阅为准):

P2-1 的 caller 冻结时机必须在注册侧,不能在投递时实时查:

  • 注册异步卡时(构造 answererOpenId 处)立即 pickTurnReplyTarget(session, originTurnId)?.senderOpenId 取值并写入 broker 的 answererOpenId,让「谁可答」成为注册快照;
  • 投递回调里的 isCurrent() 只删 lastCallerOpenId 比较,不要新增对 pickTurnReplyTarget 的实时调用。原因:replyTargets 是有界表(reply-target.ts REPLY_TARGETS_MAX = 32,pruneReplyTargets 按 updatedAt oldest-first 淘汰,仅 live cron 锚点豁免);异步卡最长挂 1 小时,期间同会话再跑 >32 轮,origin entry 会被剪掉,投递时实时取就会得到 undefined,又回到静默丢答。
  • 若注册时 pickTurnReplyTarget 取不到 sender(如自治/定时轮无人可锚),应 fail closed 不发卡并有日志,不要回退到可变的 lastCallerOpenId。

回归测试希望覆盖两条时序,且走真实 daemon 接线(不是 codex-app-runner.integration.test.ts 里把 isCurrent 换成进程存活判断的 HTTP 桩):

  1. A 发起 → 异步卡存活期间 B 在话题插话 → A 仍能作答且答案进同一原生线程;
  2. A 发起 → 卡存活期间同会话再跑 >32 轮淘汰 replyTargets 条目 → A 仍能作答(验证注册快照不依赖该表存活)。

@deepcoldy

Copy link
Copy Markdown
Owner

再补一条验收边界(接上两条自动评审意见,最终以维护者审阅为准):

P2-1 补充:自治/定时轮没有真人入站,fail-closed 需要显式第二出口

严格落成「注册时取 pickTurnReplyTarget(session, originTurnId)?.senderOpenId,取不到就不发卡」会误伤一类核心场景:定时任务与 HTTP trigger 发起的轮次没有真人入站——定时 fire 路径(session-manager.ts)的 beginReplyTargetTurn(..., scheduledTurnId) 不传 senderOpenId,trigger 续轮(worker-pool.ts 的 onTriggerTurnStarted)只调 beginNewTurn 不写 replyTargets。结果:定时/自治轮里模型按 0.162 鼓励的方式发出 request_user_input_async 时,注册侧取到 undefined,从现状「发卡(钉给历史发言人)」退化成「一张卡都不发」,而这恰恰是异步提问 "continue work while waiting" 的主要用法之一。

建议把答复人解析做成注册时一次冻结的有序回退,每一级都必须过 canOperate 现行授权校验:

  1. pickTurnReplyTarget(session, originTurnId)?.senderOpenId(真人发起轮);
  2. 定时轮:parseScheduledTurnId(originTurnId) 命中 schedule:<taskId>:<uuid> 时,经 scheduled-turn-provenance 读任务取创建者(task.ownerOpenId,校验任务仍存在、bot/chat 绑定一致、创建者未被移出授权名单);
  3. 其余无真人轮次(trigger / message-listener / bot-sourced):回退 session.ownerOpenId;
  4. 都取不到时,不要 409 静默丢弃:runner 侧把该 item 当普通输出处理,并在飞书给一条用户可见提示(例如该轮无可答复的发起人身份,问题内容见终端)。

一个落地坑:第 2 级不要直接复用 authorizeScheduledTurn 的严格形态——它对一次性任务要求该 schedule turn 仍然 live 才放行,而异步卡的设计就是比发起轮活得久(最长 1 小时),定时轮正常结束后创建者再来答题会被 liveness 检查误拒。这里只需要「任务存在 + 绑定一致 + 创建者仍在授权名单」,不要求 turn live;投递时的 worker/generation/thread 换代检查仍照常。

另外请一并考虑 silent 定时任务:异步提问卡会显式打破静默(模型主动找人),这大概率是预期行为,但建议在实现中明确取舍(静默任务是否允许弹提问卡),不要成为未定义行为。

验收回归在已有两条时序之外再加第三条,同样要求真实 daemon 接线:

  1. 定时轮(或可模拟的无真人入站轮)触发异步提问 → 任务创建者能作答、答案进入同一原生线程;创建者已被移出授权名单时有明确的用户可见结果,而不是静默无卡/静默丢答。

@deepcoldy

Copy link
Copy Markdown
Owner

补充一个与上面身份回退同级的残留点,请并入同一次修改(自动评审初步意见,最终以维护者审阅为准):

回退到「任务创建者 / session owner」时,answererOpenId 是硬锁,定时无人值守场景会变成「看得到答不了」

ask-broker.ts 的授权是 if (ask.answererOpenId && ask.answererOpenId !== by) return false,一旦设置即硬锁;而普通 ask(claude/opencode hook、包括 codex 阻塞式 requestUserInput)从不设置该字段,授权回落到 canTalk——群里任何能与 bot 对话的成员都能答(broker 注释明确 ask 是 talk-level 交互,不走 canOperate)。全仓设置 answererOpenId 的另一场景只有跨发言人建议的 owner 确认卡。

因此四级回退落到第 2/3 级(定时任务创建者 / session owner)时会产生新问题:定时轮常是无人值守任务,创建者不在线时,群里其他值守成员点不动卡片,只能等 1 小时超时——比静默不发卡只多一个中间态。两个口径二选一,请明确写进文档,不要留隐式:

  • 锁创建者:语义为「创建者的异步问答」,与真人轮一致;则必须有可见兜底(卡片文案点名唯一答复人,或超时/无人答时在群里给可见提示)。
  • 回落 canTalk(倾向):真人发起轮(第 1 级命中)保持本 PR 契约锁发起人;定时/自治轮(第 2/3 级)不设 answererOpenId,任何 canTalk 成员可答——与阻塞式 requestUserInput 及其它 hook ask 的既有口径一致,也符合无人值守任务由群内值守者代答的用途。答案只驱动该会话自身后续轮次,talk-level 影响面与现有 ask 完全相同。问题归属(这是谁发起的提问)仍可通过卡片文案/@ 创建者体现,但不做硬锁。

若采用回落 canTalk,有一处必须连带修改:当前投递回调 daemon.ts 内 if (!isCurrent() || by !== asyncCaller) return false 会把非创建者的合法答题者拒掉——删除 by !== asyncCaller 比较,信任 broker 已完成的 canTalk 授权,下游 sender/trustedCallerForTurn 直接用实际答题人 by(现有代码本来就是按 by 构造的);worker/generation/thread 的换代检查保持不变。

验收时序(在上一条三条基础上明确)

  1. 真人轮:A 发起 → 卡存活期间 B 插话 → A 仍能作答,B 不能答 A 的卡(锁发起人不被可变 lastCallerOpenId 破坏);
  2. 真人轮:卡存活期间同会话再跑 >32 轮淘汰 replyTargets 条目 → A 仍能作答(注册快照不依赖该表存活);
  3. 非共享定时 fire(顶层/新建话题,不写 replyTargets entry)触发异步提问 → 按最终选定口径,任务创建者或群内 canTalk 值守成员可作答且答案进入同一原生线程;创建者被移出授权名单 / 无可答复身份时有明确的用户可见结果,不静默;
  4. 静态结论:silent 定时任务是否允许弹提问卡需显式取舍并写进代码注释/文档(silent 语义是完全静默,弹卡与之冲突,不能是默认行为)。

@deepcoldy

Copy link
Copy Markdown
Owner

再补一条并发卡残留(接上几条自动评审初步意见,最终以维护者审阅为准):

P2(并发面):同话题多张异步卡并存时,话题文字作答会静默错配到最早注册的那张

ask-broker.ts 的 findPendingAskByAnchor 只按 (larkAppId, chatId, rootMessageId) 命中并返回最先注册的 pending ask,其注释写明前提是「同一 anchor 同时最多一个 pending ask,因为发起 ask 的 CLI 此刻正阻塞等待结果」。阻塞式 ask 满足该不变量,本 PR 的非阻塞异步卡正好打破它:

  • runner 对每个异步 item 用不同 requestId=itemId 注册,同进程 BOTMUX_ROOT_MESSAGE_ID 恒定 → 同话题多张卡 anchor 完全相同(设计上允许最多 32 张并存);
  • daemon 的入站文字拦截(daemon.ts 中 findPendingAskByAnchor 调用点)拿到的是最早注册的那张,submitCustomReply settle 它 → 该卡自己的 long-poll 继续,daemon 用那张卡的 itemId/题号包装答复送进原生线程;用户想答第二张题,答案进了第一张题的关联,第二张卡继续挂着,全程无提示;
  • 卡片底部还主动印着 custom_reply_hint「直接在话题里回复一句文字即可当答案」,等于把用户往这条歧义路径上引导。

对修法的关键约束:不能简单禁止异步卡走文字作答

ask-card.ts 渲染逻辑里 if (textReplyRequired) continue——批次中只要含文字题(options 省略)就不渲染选项按钮,这类卡按设计只能在话题里用文字回答(PR 自己的 fixture 第二题 "Which voice?" 即如此)。因此「异步卡一律只走按钮/显式答复」会让含文字题的异步批次在多卡并存时彻底无法回答。

建议落成基于卡片消息的消歧(PendingAsk.cardMessageId 已存在):

  1. anchor 下只有一张 pending ask:保持现状,话题文字直接作答;
  2. anchor 下有多张 pending ask:仅当该文字消息引用/回复了目标卡片消息(入站 parent/quote id 命中该 ask 的 cardMessageId)时才拦截作答;否则不要吞掉这条文字——按普通新一轮输入正常路由,并给一条可见提示(话题内有多张待答卡片,请点按钮或引用对应卡片作答);
  3. 多卡并存期间卡片 note 文案相应改为「请引用本卡片回复文字答案」,按钮路径不受影响。

验收时序加一条:同话题 ≥2 张并发异步卡(至少一张含文字题)时,普通文字不被静默错配(按上面 2 的规则要么精确命中引用卡、要么正常路由 + 可见提示),按钮作答各自正确。

@deepcoldy

Copy link
Copy Markdown
Owner

对上一条多卡文字作答消歧的两个实现约束(自动评审初步意见,最终以维护者审阅为准):

  1. 新增查询,不要改脏老函数。请在 ask-broker 新增 listPendingAsksByAnchor({ larkAppId, chatId, anchor }),返回该 anchor 下全部未结 ask 的 snapshot(含 cardMessageId);现有 findPendingAskByAnchor 保持「唯一命中、返回最先注册」的语义不动,继续服务既有阻塞式 ask 调用点——不要给它加 parentId 入参或改成返回数组,那会污染所有既有调用方的单卡假设。

  2. chat-scope 下必须精确匹配 parentId === cardMessageId,不能只判断「有引用」。findPendingAskByAnchor 现语义里 ask.rootMessageId === null 时匹配恒真,而 chat-scope 会话(bindSessionScopedIpcIdentity 对 scope === 'chat' 冻结 rootMessageId: null)的异步卡全部落这条腿:同一聊天范围会话里两张异步卡对任意入站文字都同时进候选。因此多卡消歧只能接受入站消息 parentId(message-parser.ts 从 message.parent_id 解析,拦截点作用域内可用)精确等于某张卡的 cardMessageId(dispatcher send 返回后已写入 broker、snapshot 不剥离);「引用了任意消息」这种宽松条件在 chat-scope 会退化成新的静默错配。回复卡片消息时飞书 root_id 仍是话题/anchor、parent_id 是被回复的卡片消息 id,anchor 命中规则不变、精确 parentId 是干净加细。

验收时序第 5 条最终形态:

  • 同话题/同聊天范围 ≥2 张并发异步卡(至少一张含文字题,即该卡无任何按钮、只能文字作答);
  • 无引用的普通文字:不被拦截吞掉,按普通新一轮输入路由,并有可见提示告知存在多张待答卡;
  • 引用(回复)目标卡片的文字:精确 settle cardMessageId 对应的那张,答案以该卡 itemId/题号包封;
  • 按钮作答各自命中正确的卡;
  • thread-scope 与 chat-scope 两种会话各覆盖一次(chat-scope 是 anchor 恒真的高危形态)。

@deepcoldy

Copy link
Copy Markdown
Owner

复审通过,之前的 2 个 P2 与并发消歧都已解决(注册侧身份冻结、定时/自治轮 canTalk、silent 不发卡、精确 parentId 消歧、真实 IPC 路由回归覆盖 thread/chat 两 scope,均已本地验证并做变异测试)。仅余两个不阻塞的小问题,方便的话可顺手修:

  1. 文档自相矛盾:docs-site/docs/{zh,en}/adapters.md 新增段落说明「定时/自治轮经授权后允许 canTalk 成员代答」,但紧接着的旧段落仍写「异步卡片只允许发起轮次的用户答复 / Only the user who initiated the originating turn may answer」。建议把旧句改为「真人发起轮仅发起人可答;定时/自治轮为群内可对话成员」之类,与新段落一致。

  2. src/codex-app-runner.ts inactiveSteer 回退分支里 turn.itemText.clear() 连续出现两次(约 2350/2352 行,后者是 rebase 前已有的一行),删掉其一即可;同文件几处新增行缩进与周围不一致(如 reconcile/inactiveSteer 段),可顺手对齐。

(自动评审初步意见,最终以维护者审阅为准。)

@deepcoldy

Copy link
Copy Markdown
Owner

交叉复审发现 1 个建议合前修掉的策略收窄(自动评审初步意见,最终以维护者审阅为准):

真人发起轮的发卡门槛用了 canOperate,比阻塞式提问更严,且无测试钉住

daemon.ts 的 asyncAskAudience 真人分支:

if (sender && !botSender) return canOperate(ds.larkAppId, ds.chatId, sender)
  ? { kind: 'human', answererOpenId: sender } : { kind: 'unavailable' };

可达场景:限制态 bot(allowedUsers=[owner],但 oncall/allowedChatGroup/teamMember/chatGrant 等 canTalk 腿放行某用户 X)。X 驱动话题轮次走的是 enforceMessageQuotaForCliInput → evaluateTalk(canTalk,与 allowedUsers 无关),模型此时发 request_user_input_async:注册侧因 X 不满足 canOperate 直接 409,卡片不创建,只在群里给「身份不可用」提示——功能对 X 不可用。

Parity 问题:阻塞式 requestUserInput 的答复鉴权是 broker 的 canTalkChecker = evaluateAskAnswerTalk(canTalk),同一个 canTalk 用户在同一会话里能答阻塞卡,异步卡却连卡都没有,与「异步提问不应比阻塞式更难答」的设计口径相反。异步答案虽然会开启一个后续轮,但普通入站开轮本就是 canTalk 级别,且点击时 broker 仍会做 canTalk 校验,不构成提权。

该分支目前无测试钉住:临时把这行放宽为无条件 { kind: 'human' },daemon 路由回归 132 例与 async 单测全部仍绿(freeze 用例的发起人本来就在 allowedUsers 里,没走过 talk-only 路径)。

建议:

  1. 真人分支改用与 broker 点击鉴权同源的 canTalk 判定(evaluateAskAnswerTalk(larkAppId, chatId, sender, chatType),人类发起无 actor),通过即冻结 answererOpenId: sender——「谁可答」仍是注册时锁定的发起人本人,canTalk 只决定「卡该不该发」;定时任务分支继续保留创建者 canOperate 校验(任务创建者权威不应下放)。
  2. 补一条 talk-only 发起人用例(bot allowedUsers=[owner]、canTalk checker 放行另一用户,该用户的 om_ 轮触发异步提问):卡片应创建且 answererOpenId 为该用户;再补一条 canTalk 也不满足时维持 409 + 可见提示。

另外,上一条 nit 里的文档矛盾在当前 head 上仍然存在(docs-site/docs/{zh,en}/adapters.md 新段写「定时/自治轮 canTalk 代答」,紧邻旧段仍写「异步卡片只允许发起轮次的用户答复」),请一并改掉旧句;src/codex-app-runner.ts 重复的 turn.itemText.clear() 同样还在。

This branch has not been deployed

No deployments
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