Skip to content

Stream Lark bot replies progressively as LLM deltas arrive#241

Closed
eanzhao wants to merge 3 commits intodevfrom
feat/2026-04-17_lark-streaming-reply
Closed

Stream Lark bot replies progressively as LLM deltas arrive#241
eanzhao wants to merge 3 commits intodevfrom
feat/2026-04-17_lark-streaming-reply

Conversation

@eanzhao
Copy link
Copy Markdown
Contributor

@eanzhao eanzhao commented Apr 17, 2026

问题

Lark bot 收到消息后,用户要静默等待 5–30s 才能看到 LLM 回复。HandleChatContent 把所有 delta 累到 StringBuilder,直到 HandleChatEnd 才一次 POST 到 Lark。

方案

  • 新增 IStreamingPlatformAdapter 能力接口(扩展 IPlatformAdapter):PostStreamingPlaceholderAsync + UpdateStreamingMessageAsync
  • LarkPlatformAdapter 实现该接口:第一条 delta 通过 Lark POST /open-apis/im/v1/messages 发占位消息并捕获 message_id;后续 delta 通过 PATCH /open-apis/im/v1/messages/{id} 覆盖内容。互动卡片载荷保持走原 send-once 路径,避免消息类型冲突。
  • ChannelUserGAgent.HandleChatContent 改为 async:首 delta 立即发占位消息,后续 delta 按 750ms 节流 PATCH;HandleChatEnd / HandleChatTimeout 通过同一 helper isFinal: true 强制 flush 最终文本,绕过节流。
  • 占位 POST 失败 / 后续 PATCH 抛异常 → session 标 DisabledSendReplyAndCompleteAsync 回退到原有 SendReplyAsync 路径。
  • 占位消息 id 只在内存,actor 重启后新 delta 另开占位,不阻塞正确性;仅属 UX 增强。

影响路径

  • agents/Aevatar.GAgents.ChannelRuntime/IPlatformAdapter.cs — 新接口 IStreamingPlatformAdapter
  • agents/Aevatar.GAgents.ChannelRuntime/Adapters/LarkPlatformAdapter.cs — 实现流式接口 + 辅助方法
  • agents/Aevatar.GAgents.ChannelRuntime/ChannelUserGAgent.cs — 引入 _streamingDeliveries per-session 状态、TryStreamingUpdateAsync helper;重写 HandleChatContent 为 async;SendReplyAndCompleteAsync 增加 streaming-first 分支
  • test/Aevatar.GAgents.ChannelRuntime.Tests/ChannelUserGAgentContinuationTests.cs — 新增 happy path + placeholder 失败回退两条测试,提供 RecordingStreamingPlatformAdapter

验证

  • dotnet build agents/Aevatar.GAgents.ChannelRuntime/Aevatar.GAgents.ChannelRuntime.csproj — 0 errors
  • dotnet test test/Aevatar.GAgents.ChannelRuntime.Tests/Aevatar.GAgents.ChannelRuntime.Tests.csproj — 228/228 pass(原 226 + 2 新增)
  • bash tools/ci/architecture_guards.sh — 全部通过
  • 部署后在 Lark 真实机器人上观察:发送 "写一段 300 字的介绍" 这类长回复,确认占位消息先出现、内容按 ~750ms 节奏增量刷新、最终结果正确

🤖 Generated with Claude Code

On the first LLM text delta, post a placeholder Lark message and capture its
message_id; subsequent deltas PATCH the same message at most once per 750ms
so users see the reply stream in instead of sitting through 5-30s of silence.
HandleChatEnd / HandleChatTimeout always flush the final content via a last
PATCH; if the placeholder post or any update throws, streaming is disabled
for the session and the existing SendReplyAsync path is used as fallback.

Interface split: new IStreamingPlatformAdapter extends IPlatformAdapter with
PostStreamingPlaceholderAsync / UpdateStreamingMessageAsync. LarkPlatformAdapter
implements it; adapters without streaming support keep the single-POST path
unchanged. ChannelUserGAgent tracks placeholder state per session in-memory
only — on actor restart, new deltas simply open a fresh placeholder, so
streaming is a best-effort UX enhancement that never blocks correctness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 577a801943

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +507 to +511
if (!delivery.Succeeded)
{
if (isFinal)
{
Logger.LogWarning(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fall back to send-once when final stream PATCH fails

When UpdateStreamingMessageAsync returns a non-success result, TryStreamingUpdateAsync immediately returns false without disabling streaming state. This leaves MessageId active, so SendReplyAndCompleteAsync keeps taking the streaming branch on both HandleChatEnd and the timeout retry, and never calls SendPlatformReplyAsync; with forceComplete=true on timeout, the session is then closed while users can be left with only stale placeholder/partial text (for example when PATCH is rejected with cannot_patch_to_interactive or a stale message_id).

Useful? React with 👍 / 👎.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 17, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.48%. Comparing base (8861b4c) to head (434674c).
⚠️ Report is 258 commits behind head on dev.

@@            Coverage Diff             @@
##              dev     #241      +/-   ##
==========================================
- Coverage   68.49%   68.48%   -0.01%     
==========================================
  Files        1108     1108              
  Lines       77793    77793              
  Branches    10178    10178              
==========================================
- Hits        53287    53280       -7     
- Misses      20612    20617       +5     
- Partials     3894     3896       +2     
Flag Coverage Δ
ci 68.48% <ø> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

eanzhao and others added 2 commits April 17, 2026 21:22
HandleRegisterAsync (PR #240 + 42be6a9) already returns 202 with
projection_ready + projection_warning when the register projection
has not materialized in time — avoids 5xx-triggered retries that
would duplicate-write fresh registrationIds.

The Tool path (nyxid-chat action=register) still followed the old
silent-accept shape: status="accepted" + a weak "note" field that
LLMs routinely dropped. The user would paste the returned
registration_id into the Feishu developer console, hit the callback
URL, and see "Challenge code 没有返回" forever because the id
never materialized into the readmodel.

Align with the HTTP endpoint: always return status="registered" and
surface the degraded state through projection_ready (bool) +
projection_warning (nullable string). Caller LLMs can then warn the
user before advertising the callback URL as usable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@eanzhao
Copy link
Copy Markdown
Contributor Author

eanzhao commented Apr 23, 2026

关闭此 PR:底层架构已在 dev 上演进到新方向,PR 涉及的主要文件全部被移除或退役,rebase 已无意义。

依据

本 PR 改动 dev 当前状态
ChannelUserGAgent.cs (+193/-29) commit de28ac1e 删除了整个 792 行文件
LarkPlatformAdapter.cs (+134/-1) 已退役为兼容空壳;SendReplyAsync 返回 lark_direct_platform_reply_retired use_nyx_channel_relay_reply
IPlatformAdapter.cs (+33) 签名已重写,SendReplyAsync 必须通过 NyxIdApiClient
ChannelUserGAgentContinuationTests.cs (+274) 已被删除

aevatar 的 ChannelRuntime 已完全不持有 Lark 凭据(f8dfd732 Remove channel runtime credential ownership),所有 Lark 出站流量走 NyxIdApiClient → Nyx relay 的 POST /api/v1/channel-relay/reply;会话编排换成 LarkConversationInboxRuntime + LarkConversationTurnRunner + ConversationReplyGenerator 新架构,ChannelUserGAgent 实体整个不复存在。

如果之后仍要做 Lark 渐进流式回复

需要重新设计,不在此 PR 延续:

  1. NyxID 侧:扩展 POST /api/v1/channel-relay/reply 支持编辑既有消息(新增 action 或 endpoint,携带 message_id PATCH 到 Lark /open-apis/im/v1/messages/{id})。
  2. aevatar 侧:改点在 LarkConversationTurnRunner / ConversationReplyGenerator / ChannelPlatformReplyService,不是本 PR 涉及的任何文件。
  3. 跨仓库协调,建议先在 NyxID 开 issue 讨论 relay 是否接受 edit-message 语义。

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