Skip to content

fix(lark): 终态仅更新页脚以保留回复正文排版 - #1855

Merged
deepcoldy merged 1 commit into
deepcoldy:masterfrom
wzomg:codex/preserve-terminal-card-body
Oct 13, 2026
Merged

deepcoldy merged 1 commit into
deepcoldy:masterfrom
wzomg:codex/preserve-terminal-card-body

Conversation

@wzomg

@wzomg wzomg commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

普通飞书回复原本分段正常,轮次结束、追加页脚状态后,列表后的小节标题可能粘到上一条列表末尾。#1722 引入的终态逻辑会读取消息卡片、修改页脚,再整卡回写;消息回读中的 Markdown 已被飞书归一化,重新提交会触发二次解析。

改为通过已有 CardKit 接口仅更新 botmux_reply_footer.content,正文、标题和交互控件不进入更新请求。使用固定 sequence 和稳定 UUID 重试;缺少页脚或局部更新失败时,沿用独立结束提示卡,避免整卡降级回写。

复现过程(以下文字为虚构示例)

阶段 列表项与下一小节的边界
原发送正文 - example\n\n**Next section**
首次消息回读 - example\n**Next section**
旧逻辑整卡回写后 - example**Next section**
本修复仅更新页脚后 正文与更新前逐字一致

影响范围

只调整普通 Lark 回复卡片的终态收尾路径,适用于共用该路径的 CLI。未改动 Markdown 转换器、CLI 适配器、其他 IM、无可见输出时的流式状态卡路径。CardKit 不可用、权限不足或 sequence 冲突时可能多出一张独立结束提示卡,不会重新提交正文。

验证

  • Bun 1.4.2,bun run build。
  • bun run test test/turn-terminal-receipt.test.ts test/turn-terminal-receipt-card.test.ts test/lark-transport-boundary.test.ts test/lark-topic-write-guard.test.ts。
  • 77 个相关测试通过;终态生命周期用例覆盖 TraeX 与 Codex 两种 CLI 身份。
  • 回归覆盖页脚局部请求、嵌套页脚、重复状态、缺失页脚、重试/降级和跨群写入拒绝。
  • 在 v3.42.0 上进行了两组真实卡片 API 对照:旧逻辑均复现正文边界换行丢失;仅页脚更新均成功且正文回读前后逐字一致。该验证直接调用源码中的发送/更新接口,未切换或重启运行中的 daemon。

为保护测试现场信息,未公开原始聊天内容、截图、身份标识、消息链接或原始日志;以上示意和提交中的测试数据均为虚构。

@deepcoldy

Copy link
Copy Markdown
Owner

你好 @wzomg,这个 PR 的自动评审群已建好,麻烦点下面的链接加入飞书群,评审结论和后续沟通都在群里进行(链接一年内有效):

https://applink.larkoffice.com/client/chat/chatter/add_by_link?link_token=a9dmf05f-cecb-4bf4-9234-139a9164bc32

另外,目前自动拉群名单里暂时没有你的飞书账号,所以这次没能直接把你拉进群;方便的话可以把 GitHub 账号和飞书信息补录到这个名单文档里,补好后后续评审就能自动拉你入群:https://bytedance.larkoffice.com/wiki/WJ1nwWbtxi89erkNGNbcgkt9nUe

(这是自动评审流程,感谢贡献~)

@wzomg

wzomg commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@deepcoldy 已加入评审群,并在名单文档中补齐了 GitHub 账号和飞书信息,感谢!

@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见(两轮独立复核,以维护者最终确认为准)

整体方向正确、实现干净:终态只对 botmux_reply_footer 做 CardKit 局部 partial_element 更新,彻底避免「读回归一化后的整卡 → 二次解析丢空行 → 整卡回写」造成的排版破坏;无 footer / 非 Card 2.0 / CardKit 失败(0/1/5s 三次同 uuid 重试)时降级为另发独立结束卡,始终不重提正文。固定 sequence + 稳定 uuid(51 字符,接口上限 64)、两道写权限校验(idConvert 与 patch 前的 assertMessageWriteAllowed)都在。本地 bun run build(含 tsc)、相关测试与变异探针均通过。

有 1 处需要修改的 CI 红灯(测试代码的双运行时兼容问题,非生产代码缺陷),另有真机验证请求与几条不阻塞的小观察。

1.(需修改)bun-test 腿必挂:新增重试用例依赖 fake timers,Bun 下刷不出多跳退避

CI 的 bun-test 腿(Bun 1.4.2)红在本 PR 新增用例:

test/turn-terminal-receipt.test.ts
  ✗ retries one stable footer operation then falls back without replacing the body
    Expected number of calls: 3
    Received number of calls: 0

根因:用例用 vi.useFakeTimers() + vi.advanceTimersByTimeAsync(7000) 驱动生产代码 0/1s/5s 的退避链,但 test/bun-test-shim.ts 的 advanceTimersByTimeAsync 只做一次 jest.advanceTimersByTime + 一次 await Promise.resolve(),刷不动 Bun 下「0ms → await → 1s → await → 5s」的多级异步续排。同文件已有的 retries transport failure with the same UUID 当初遇到的是同一个问题(注释里也写明了 Bun 与 Node 在假定时器周围推进 Promise 续排的差异),约定写法是真实退避 + vi.waitFor。建议照此修改:

  it('retries one stable footer operation then falls back without replacing the body', async () => {
    const ds = makeDs();
    bindLarkTurn(ds, 'om_turn_patch_failure');
    vi.mocked(patchCardStreamElement)
      .mockRejectedValueOnce(new Error('CardKit unavailable'))
      .mockRejectedValueOnce(new Error('CardKit unavailable'))
      .mockRejectedValueOnce(new Error('CardKit unavailable'));
    __testOnly_setupWorkerHandlers(ds, ds.worker as any);
    (ds.worker as any).emit('message', {
      type: 'explicit_reply_observed', turnId: 'om_turn_patch_failure',
      messageId: 'om_answer', terminalCarrier: 'standard_reply_card',
    } satisfies WorkerToDaemon);
    (ds.worker as any).emit('message', terminalMsg('om_turn_patch_failure'));
    // Use the production 0/1s/5s backoff with real timers. Bun and Node advance
    // promise continuations around fake timers differently, which made this
    // assertion runtime-dependent even though the retry was sound.
    await vi.waitFor(() => expect(patchCardStreamElement).toHaveBeenCalledTimes(3), {
      timeout: 8_000,
      interval: 50,
    });
    const calls = vi.mocked(patchCardStreamElement).mock.calls;
    expect(calls[1]).toEqual(calls[0]);
    expect(calls[2]).toEqual(calls[0]);
    await vi.waitFor(() => expect(sessionReplyMock).toHaveBeenCalledTimes(1), {
      timeout: 2_000,
      interval: 20,
    });
    expect(updateMessage).not.toHaveBeenCalled();
  });

该改法已经分别在两个运行时下实测:vitest 26/26、bun test --timeout=180000(CI runner 使用的参数)26/26,用例约 6s,在两侧超时预算内。

test (1/3) 另一个失败 test/child-env.test.ts > real node-pty child ...(pty output incomplete — a lost read, not an env leak)是真实 node-pty 的时序 flake,与本 PR 无关(本 PR 不涉及 PTY/env),多轮复跑均通过,重跑该分片即可。

2.(合码前请真机确认)两类卡片的 CardKit 局部更新有效性

单测无法覆盖飞书服务端行为,PR 描述中的两组 A/B 很好;建议 fleet 真机再覆盖以下两点:

  • 配置了「语音总结」的 bot:src/cli.ts 装配回复卡时,语音按钮开启的情况下 footer 不在 body.elements 顶层,而是嵌在 column_set > column > elements[] 内。读侧的 findCardElementById 能递归找到它,但 CardKit cardElement.patch 对嵌套在 column 内的 element_id 是否可定位更新,需要真机确认一次(若不可用会走重试耗尽 → 独立结束卡的降级,不会破坏正文,但会多一张卡)。
  • 普通 sendMessage 回复卡(非流式卡)+ idConvert 的有效性:此前 idConvert 的生产用法都作用于 CardKit 流式卡,本 PR 首次用于普通已发送回复卡;文档支持,也请在真机普通回复上确认终态后正文与更新前逐字一致、页脚正确追加、CardKit 权限不足/不可用时按预期降级为独立卡。

3. 不阻塞的小观察(P3,供参考)

  • 旧函数 appendTurnTerminalReceiptToCard 已无生产调用方,只剩其单元测试引用。考虑到主干近期做过死代码清理,建议随本 PR 一并删除该函数与其测试,或留一行注释说明它作为旧整卡路径的回滚实现保留,二选一即可。
  • 与之相关的一个历史迁移缺口:旧函数会清理 feat(lark): 按最后载体自动收尾轮次状态 #1722 第一版在卡内留下的独立终态元素(element_id=botmux_turn_terminal_receipt 及其前置 divider),新的 footer patch 不再清理它。极端情况下(第一版时期打过终态的老卡、daemon 重启后终态被重放)可能出现旧终态行与新结束卡并存;不影响正文排版、概率极低,若保留旧函数则无需处理,若删除可顺手在 footer patch 里保留对该 id 的清理。
  • footer 固定使用 sequence=1。当前自动流式补丁只打独立的流式状态卡、反馈按钮刷新走卡片回调整体替换,均不占回复卡的 CardKit 序号;仅记录:若未来人工对同一张回复卡执行 botmux card stream open 会发生序号冲突(当前不可达)。

@deepcoldy
deepcoldy force-pushed the codex/preserve-terminal-card-body branch from d63544a to fe30263 Compare October 13, 2026 12:38
@deepcoldy
deepcoldy force-pushed the codex/preserve-terminal-card-body branch from fe30263 to feedda0 Compare October 13, 2026 12:44
@deepcoldy
deepcoldy force-pushed the codex/preserve-terminal-card-body branch from feedda0 to cfc08d6 Compare October 13, 2026 13:24
@deepcoldy
deepcoldy merged commit 7d11a95 into deepcoldy:master Oct 13, 2026
10 checks passed
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