Skip to content

fix(qqofficial): finalize C2C stream to prevent replies reverting to the first fragment - #10067

Open
Linyesantan wants to merge 2 commits into
AstrBotDevs:masterfrom
Linyesantan:fix/qqofficial-c2c-stream-finalize-tests
Open

Linyesantan wants to merge 2 commits into
AstrBotDevs:masterfrom
Linyesantan:fix/qqofficial-c2c-stream-finalize-tests

Conversation

@Linyesantan

@Linyesantan Linyesantan commented Sep 13, 2026

Copy link
Copy Markdown

问题

QQ 官方机器人(WebSocket,C2C 私聊)开启流式输出后,回复会先正常流式增长为完整内容,随后回滚成开头的几个字;流式消息有时还会长期停在“生成中…”。

真实案例:会话数据库 data_v4.db 里保存的是完整 850 字 回复,但 QQ 客户端里那条流式消息最终只剩首包 5 个字,且全程没有任何报错日志。

关联 issue:#10066

根因

send_streaming 每次节流发送后都会清空 self.send_buffer。正常结束时执行的 state=10 收尾分支,如果 buffer 已被最后一次节流发送清空,_post_send 会因为 if not self.send_buffer: return None 直接返回——结束帧从未发出,QQ 侧流式消息始终没有 finalize,超时后回滚 / 停留在未完成状态。异常、断流路径同样会丢弃尚未发送的尾段。

改动

  • 追踪 full_text / sent_len:正常结束时把未下发的尾段以 state=10 补齐;若尾段为空则发送 "\n" 收尾帧(满足 QQ API 结尾换行要求,且不会被 _post_send 的“空 buffer 早退”丢弃)。
  • 断流 / 异常路径同样把未下发尾段以 state=10 补发,避免消息冻结在最后一个分片。
  • 若中途分片从未拿到 stream id,则降级为普通消息补发全文。
  • 新增回归测试 tests/test_qqofficial_stream_finalize.py

#9875 的关系

本 PR 包含 #9875 的修复逻辑(作者 @VZService-AI,commit bb19453),并按 #5264 的要求补上了回归测试。若 #9875 先行补测试并合并,本 PR 可关闭。

Test plan

  • 新增回归测试:pytest tests/test_qqofficial_stream_finalize.py → 3 passed
    • 节流发送已吃掉全部内容时,仍必须发出 state=10 结束帧;
    • 存在未下发尾段时,最终帧只携带该尾段(不重复);
    • 上游中途断流时,未下发尾段仍以 state=10 补发。
  • 真实 QQ 官方 C2C 环境验证长回复不再回滚。

Summary by Sourcery

Ensure QQ Official C2C streaming replies are finalized reliably and preserve complete generated content across normal and interrupted delivery paths.

Bug Fixes:

  • Finalize QQ Official C2C streaming replies so completed messages no longer revert to an initial fragment or remain stuck in a generating state.
  • Flush unsent content when streaming ends unexpectedly, and fall back to sending the full response as a regular message when no stream ID is available.

Tests:

  • Add regression coverage for finalizing fully flushed streams, sending only the unsent tail, and recovering remaining content after upstream interruption.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py" line_range="266" />
<code_context>
             self.message_obj.raw_message
         )  # 提前获取,避免 generator 为空时 NameError
+        # 累积已生成全文与已下发长度,用于断流兜底,避免回复被掐断
+        full_text = ""
+        sent_len = 0
+
</code_context>
<issue_to_address>
**issue (broader_impact):** When a `break` marker arrives after the previous throttled send has already cleared `self.send_buffer`, the `if self.send_buffer` guard skips the `state=10` frame, and this code resets `stream_payload` without finalizing the existing QQ stream. The tool-call stream therefore remains unfinished and can still revert or remain stuck.

**Triggers:** When a tool call follows a throttled C2C fragment whose buffer is already empty.

**Suggested fix:** Finalize the active stream on `break` whenever a stream id exists, using the same tail-or-newline logic as normal completion, rather than guarding finalization only on `self.send_buffer`.
</issue_to_address>

### Comment 2
<location path="astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py" line_range="302-306" />
<code_context>
+                    self.send_buffer.chain.append(Plain(text=tail if tail else "\n"))
+                    stream_payload["state"] = 10
+                    ret = await self._post_send(stream=stream_payload)
+                elif full_text:
+                    # 未拿到流式消息 id(中间分片返回无 id):降级为普通消息补发全文
+                    self.send_buffer = MessageChain(use_t2i_=False, type="segment")
+                    self.send_buffer.chain.append(Plain(text=full_text))
+                    ret = await self._post_send()
             else:
                 ret = await self._post_send()
</code_context>
<issue_to_address>
**issue (bug_risk):** When an intermediate `state=1` request has already delivered content but its response contains no message id, the final branch sends the entire `full_text` as an ordinary message. The user receives the already-delivered prefix in the stream and then a second ordinary message containing the complete response, duplicating the streamed content.

**Triggers:** When a `state=1` API response lacks an extractable message id after the request has been accepted.

**Suggested fix:** Do not send the full response as a separate ordinary message after a partial stream has been delivered; either recover the stream id or send only an appropriate non-duplicating fallback.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and if the accumulated-length or stream-ID handling is wrong, the finalization path could duplicate part or all of a reply, or send a fallback message after a partial stream. Reverting would stop future occurrences but could not retract messages already delivered to users.

Blocking findings: astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py:266, astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py:306


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

}
last_edit_time = 0
# 工具段结束后已下发前缀不再延续,重置全文追踪
full_text = ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (broader_impact): When a break marker arrives after the previous throttled send has already cleared self.send_buffer, the if self.send_buffer guard skips the state=10 frame, and this code resets stream_payload without finalizing the existing QQ stream. The tool-call stream therefore remains unfinished and can still revert or remain stuck.

Triggers: When a tool call follows a throttled C2C fragment whose buffer is already empty.

Suggested fix: Finalize the active stream on break whenever a stream id exists, using the same tail-or-newline logic as normal completion, rather than guarding finalization only on self.send_buffer.

Comment on lines +302 to +306
elif full_text:
# 未拿到流式消息 id(中间分片返回无 id):降级为普通消息补发全文
self.send_buffer = MessageChain(use_t2i_=False, type="segment")
self.send_buffer.chain.append(Plain(text=full_text))
ret = await self._post_send()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): When an intermediate state=1 request has already delivered content but its response contains no message id, the final branch sends the entire full_text as an ordinary message. The user receives the already-delivered prefix in the stream and then a second ordinary message containing the complete response, duplicating the streamed content.

Triggers: When a state=1 API response lacks an extractable message id after the request has been accepted.

Suggested fix: Do not send the full response as a separate ordinary message after a partial stream has been delivered; either recover the stream id or send only an appropriate non-duplicating fallback.

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