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

fix(qqofficial): flush remaining stream tail on finish/break to prevent truncated C2C replies#9875
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.

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.

1 participant