Repository navigation
fix(qqofficial): flush remaining stream tail on finish/break to prevent truncated C2C replies - #9875
VZService-AI wants to merge 2 commits into
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:
|
…finish/break to prevent tru
…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.
|
补充一个真实环境复现,佐证这个 PR 的必要性: 环境:AstrBot 4.27.4 + QQ 官方机器人(WebSocket,C2C 私聊), 现象:一条回复先正常流式增长成完整内容,随后被回滚成开头几个字。
根因与 PR 描述一致: 已在本地按本 PR(bb19453)的改动做了 backport,正在验证。对应 issue:#10066。 另外建议:仓库 issue #5264 要求后续贡献补充测试,而本 PR 目前只改了 1 个文件、没有测试。可以补一个针对「最后一次节流发送吃掉全部内容 → 仍必须发 state=10」以及「断流/异常时补发尾段」的回归测试(可参考 cc @Soulter 方便时帮忙看下,这个 bug 在最新 |
…finish/break to prevent tru
…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.
…finish/break to prevent tru
…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.
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: