fix(qqofficial): flush remaining stream tail on finish/break to prevent truncated C2C replies - #9875
Conversation
…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.
There was a problem hiding this comment.
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
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
…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.
|
Thanks for the review — both points are valid and now addressed in bb19453:
|
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):self.send_bufferis cleared (around line 171).exceptbranch only setself.send_buffer = None, discarding the unsent tail. The displayed C2C message froze at the last successfully appended fragment.state=10against 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
full_text(accumulated plain text) andsent_len(length already sent).state=10chunk on the same stream so the reply completes in place.except): flush the unsent tail as astate=10chunk 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
Summary by Sourcery
Ensure QQ Official C2C streaming replies deliver and finalize all generated content, including when the upstream stream ends unexpectedly.
Bug Fixes:
Enhancements: