Skip to content

fix(qqofficial): flush remaining stream tail on finish/break to prevent truncated C2C replies - #9875

Open
VZService-AI wants to merge 2 commits into
AstrBotDevs:masterfrom
VZService:fix/qqofficial-c2c-streaming-truncation
Open

VZService-AI wants to merge 2 commits into
AstrBotDevs:masterfrom
VZService:fix/qqofficial-c2c-streaming-truncation

Conversation

@VZService-AI

@VZService-AI VZService-AI commented Aug 30, 2026 •

Copy link
Copy Markdown

Summary

Fixes truncated C2C streaming replies on the QQ Official (qqofficial) platform when the upstream LLM stream ends early or raises before the next 1s throttle tick.

Root cause

In QQOfficialMessageEvent.send_streaming (astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py):

  • After each throttled intermediate chunk, self.send_buffer is cleared (around line 171).
  • When the upstream generator breaks (e.g. an unstable or free LLM provider disconnects) before the next tick, the except branch only set self.send_buffer = None, discarding the unsent tail. The displayed C2C message froze at the last successfully appended fragment.
  • On normal completion, the finalize branch sent state=10 against an already-cleared buffer, so no closing chunk was emitted.

The full reply was still generated and stored in the conversation DB; only the displayed stream was cut off.

Fix

  • Track full_text (accumulated plain text) and sent_len (length already sent).
  • On stream finish: if there is an unsent tail, send it as a state=10 chunk on the same stream so the reply completes in place.
  • On break (except): flush the unsent tail as a state=10 chunk on the same stream (or fall back to a plain message when the stream cannot be finalized, e.g. non-C2C or no stream id yet).

This keeps the live typing progress during generation and guarantees the user sees the complete reply instead of a frozen fragment. Non-C2C and plain send paths are unchanged.

Test plan

  • Stream a reply from an unstable provider on a QQ Official C2C chat; verify the full reply is shown after the stream breaks.
  • Stream a normal reply from a stable provider; verify typing progress and a finalized message.
  • Verify tool-call (segmented) replies still work.

Summary by Sourcery

Ensure QQ Official C2C streaming replies deliver and finalize all generated content, including when the upstream stream ends unexpectedly.

Bug Fixes:

  • Prevent truncated QQ Official C2C streaming replies by flushing unsent content when streams finish normally or terminate unexpectedly.

Enhancements:

  • Preserve in-progress typing updates while ensuring completed replies are finalized on the existing stream, with fallback delivery when stream finalization is unavailable.

…nt truncated replies

When the upstream LLM stream breaks (e.g. unstable or free providers) before
the next 1s throttle tick, the C2C streaming message froze at the last
successfully sent fragment. The exception handler discarded the unsent tail
and the normal-end path sent state=10 against an already-cleared buffer, so
the displayed reply was truncated even though the full text was generated.

Track the full generated text and the sent length. On stream finish or break,
flush the remaining tail as a state=10 chunk on the same stream (or fall back
to a plain message when streaming cannot be finalized). The user now sees the
complete reply instead of a frozen fragment, while keeping the live typing
progress during generation.
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. labels Aug 30, 2026

@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="189-194" />
<code_context>
-                stream_payload["state"] = 10
-                ret = await self._post_send(stream=stream_payload)
+                # 结束流式对话:把尚未下发的尾段以 state=10 补齐,避免回复被截断
+                tail = full_text[sent_len:] if full_text else ""
+                if tail and stream_payload.get("id") is not None:
+                    self.send_buffer = MessageChain(use_t2i_=False, type="segment")
+                    self.send_buffer.chain.append(Plain(text=tail))
+                    stream_payload["state"] = 10
+                    ret = await self._post_send(stream=stream_payload)
             else:
                 ret = await self._post_send()
</code_context>
<issue_to_address>
**issue (bug_risk):** When the last generated delta is sent by the throttled intermediate path, `sent_len` equals `len(full_text)`, so `tail` is empty and no `state=10` request is made. The C2C stream therefore remains in `state=1` and is never finalized.

**Triggers:** When the stream ends immediately after a throttled send with no additional unsent characters.

**Suggested fix:** Send an explicit empty or final state=10 request when `stream_payload` has an active stream and `tail` is empty, according to the QQ API's finalization requirements.
</issue_to_address>

### Comment 2
<location path="astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py" line_range="190-194" />
<code_context>
-                ret = await self._post_send(stream=stream_payload)
+                # 结束流式对话:把尚未下发的尾段以 state=10 补齐,避免回复被截断
+                tail = full_text[sent_len:] if full_text else ""
+                if tail and stream_payload.get("id") is not None:
+                    self.send_buffer = MessageChain(use_t2i_=False, type="segment")
+                    self.send_buffer.chain.append(Plain(text=tail))
+                    stream_payload["state"] = 10
+                    ret = await self._post_send(stream=stream_payload)
             else:
                 ret = await self._post_send()
</code_context>
<issue_to_address>
**issue (bug_risk):** On normal completion, an unsent tail is discarded when the previous send did not produce a response ID: the finalization branch requires `stream_payload.get("id") is not None` and has no plain-message fallback. The method then returns with the tail absent from the displayed reply.

**Triggers:** When the intermediate send succeeds but `_extract_response_message_id` cannot obtain an ID from its return value.

**Suggested fix:** Use the same ordinary-message fallback as the exception path, or finalize the stream using the response/reference information supported by the API.

```suggestion
                if tail and stream_payload.get("id") is not None:
                    self.send_buffer = MessageChain(use_t2i_=False, type="segment")
                    self.send_buffer.chain.append(Plain(text=tail))
                    stream_payload["state"] = 10
                    ret = await self._post_send(stream=stream_payload)
                elif tail:
                    self.send_buffer = MessageChain(use_t2i_=False, type="segment")
                    self.send_buffer.chain.append(Plain(text=tail))
                    ret = await self._post_send()
```
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and if the tail accounting is wrong, the fallback can send duplicated, truncated, or plain-text-only content as an additional C2C reply. Reverting would stop future occurrences but cannot retract messages already delivered to users.

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


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py Outdated
…lback

Address reviewer findings on the streaming tail flush:

1. Always send a state=10 finalization frame when an active stream id exists,
   even if the remaining tail is empty. Previously a tail-only guard skipped
   finalization whenever every chunk had already been delivered by throttled
   intermediate sends, leaving the C2C stream stuck in "generating" forever.
   Use a "\n" marker as the final frame content because empty content is
   dropped by _post_send and the protocol requires the final frame to end with
   "\n".

2. When no stream id was obtained from the intermediate sends, fall back to a
   plain (non-streaming) message carrying the full generated text instead of
   silently discarding the tail. Apply the same fallback in the break/exception
   path so the stream is never re-sent as a duplicate full message.
@VZService-AI

Copy link
Copy Markdown
Author

Thanks for the review — both points are valid and now addressed in bb19453:

  1. Finalization is now sent unconditionally when an active stream id exists. The empty-tail case (all chunks already delivered by throttled intermediate sends) was exactly the cause of the "stuck in generating" symptom. A "\n" marker is used as the final frame content so _post_send does not drop it (empty content returns early) and the protocol's trailing-"\n" requirement is met.

  2. When no stream id was obtained from the intermediate sends, the full generated text is now delivered via a plain (non-streaming) message instead of being discarded. The same fallback is applied in the break/exception path, and it no longer re-sends the full text as a duplicate streamed message.

buyun14 pushed a commit to buyun14/AstrBot that referenced this pull request Sep 3, 2026
buyun14 added a commit to buyun14/AstrBot that referenced this pull request Sep 3, 2026
…strBotDevs#9875/AstrBotDevs#9863

- send_streaming: the no-stream-id fallback resent the full text (duplicating
  the already-delivered prefix) and rebuilt a Plain-only chain, dropping rich
  media accumulated since the last throttled slice. Flush the accumulated
  buffer instead (it holds exactly the undelivered tail), and after the
  state=10 close frame send buffer media as a separate normal message. The
  error fallback now shares the same flush logic via a local closure.
- qqofficial_http: asyncio.wait_for(acquire, timeout) leaks a bounded slot on
  Python <= 3.11 when the acquire completes concurrently with the timeout.
  Drive the acquire as a task and settle it explicitly: cancel a still-pending
  acquire, or release the slot it completed with, so the pool cannot shrink.
- tests: cover tail-only flush (finish and error paths), media-preserving
  close frame, real-timeout slot accounting, and the racy-completed-acquire
  settlement.
@Linyesantan

Copy link
Copy Markdown

补充一个真实环境复现,佐证这个 PR 的必要性:

环境:AstrBot 4.27.4 + QQ 官方机器人(WebSocket,C2C 私聊),streaming_response = true。

现象:一条回复先正常流式增长成完整内容,随后被回滚成开头几个字。

  • 会话数据库 data_v4.db 中该条 assistant 回复是完整 850 字;
  • QQ 客户端里同一条流式消息最终只剩首包的 5 个字;
  • 全程无报错日志,流式消息还会停在“生成中…”。

根因与 PR 描述一致:send_streaming 每次节流发送后都会清空 send_buffer,正常收尾时 state=10 对空 buffer 调用 _post_send,会被 if not self.send_buffer: return None 直接返回,导致结束帧从未发出 → QQ 侧始终没有 finalize。

已在本地按本 PR(bb19453)的改动做了 backport,正在验证。对应 issue:#10066。

另外建议:仓库 issue #5264 要求后续贡献补充测试,而本 PR 目前只改了 1 个文件、没有测试。可以补一个针对「最后一次节流发送吃掉全部内容 → 仍必须发 state=10」以及「断流/异常时补发尾段」的回归测试(可参考 tests/test_qqofficial_stream_buffer_copy.py),会更利于合并。

cc @Soulter 方便时帮忙看下,这个 bug 在最新 master 和 v4.28.0 上依然存在。

buyun14 pushed a commit to buyun14/AstrBot that referenced this pull request Sep 17, 2026
buyun14 added a commit to buyun14/AstrBot that referenced this pull request Sep 17, 2026
…strBotDevs#9875/AstrBotDevs#9863

- send_streaming: the no-stream-id fallback resent the full text (duplicating
  the already-delivered prefix) and rebuilt a Plain-only chain, dropping rich
  media accumulated since the last throttled slice. Flush the accumulated
  buffer instead (it holds exactly the undelivered tail), and after the
  state=10 close frame send buffer media as a separate normal message. The
  error fallback now shares the same flush logic via a local closure.
- qqofficial_http: asyncio.wait_for(acquire, timeout) leaks a bounded slot on
  Python <= 3.11 when the acquire completes concurrently with the timeout.
  Drive the acquire as a task and settle it explicitly: cancel a still-pending
  acquire, or release the slot it completed with, so the pool cannot shrink.
- tests: cover tail-only flush (finish and error paths), media-preserving
  close frame, real-timeout slot accounting, and the racy-completed-acquire
  settlement.
buyun14 pushed a commit to buyun14/AstrBot that referenced this pull request Sep 17, 2026
buyun14 added a commit to buyun14/AstrBot that referenced this pull request Sep 17, 2026
…strBotDevs#9875/AstrBotDevs#9863

- send_streaming: the no-stream-id fallback resent the full text (duplicating
  the already-delivered prefix) and rebuilt a Plain-only chain, dropping rich
  media accumulated since the last throttled slice. Flush the accumulated
  buffer instead (it holds exactly the undelivered tail), and after the
  state=10 close frame send buffer media as a separate normal message. The
  error fallback now shares the same flush logic via a local closure.
- qqofficial_http: asyncio.wait_for(acquire, timeout) leaks a bounded slot on
  Python <= 3.11 when the acquire completes concurrently with the timeout.
  Drive the acquire as a task and settle it explicitly: cancel a still-pending
  acquire, or release the slot it completed with, so the pool cannot shrink.
- tests: cover tail-only flush (finish and error paths), media-preserving
  close frame, real-timeout slot accounting, and the racy-completed-acquire
  settlement.

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

area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants