Skip to content

fix(qqofficial): always send the state=10 closing frame in C2C streaming - #10069

Merged
w31r4 merged 3 commits into
AstrBotDevs:masterfrom
he-yufeng:fix/qqofficial-stream-close-frame
Sep 14, 2026
Merged

fix(qqofficial): always send the state=10 closing frame in C2C streaming#10069
w31r4 merged 3 commits into
AstrBotDevs:masterfrom
he-yufeng:fix/qqofficial-stream-close-frame

Conversation

@he-yufeng

@he-yufeng he-yufeng commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #10066.

问题

QQ C2C 流式协议要求以 state=10 分片收尾(content 以 \n 结尾)。当前实现只在 buffer 还有未发内容时才发这个收尾帧:节流中间分片(state=1,增量追加)已经把全文发完、生成器以空 buffer 收尾时,收尾帧根本不会发出。QQ 侧等不到结束帧,超时后把整条流式消息回滚到首包,完整 850 字的回复最终只显示开头几个字;有时流式消息长期停在「生成中」。

复现链条与 issue 的三条观察一一对应:中间分片增量发出全文(客户端先长到完整)→ 生成器结束时 self.send_buffer 为空 → _post_send 直接 return → 无 state=10 → 超时回滚到首包 5 字。

修法

所有开着的流式段都补收尾:新增 _close_stream_segment 共用收尾,流已开(有分片 id)但尾 buffer 为空时发送最小 "\n" 收尾帧(满足 QQ 对 state=10 content 的 \n 结尾要求);从未发出任何分片(无 id)时维持原样不发。tool_call break 路径走同一辅助,空 buffer 但流已开的段也不再被遗弃。

测试

tests/test_qqofficial_stream_buffer_copy.py 新增两条回归:

  • 中间分片冲掉全文后空尾收尾:断言帧序列为 (state=1, 全文) + (state=10, "\n")(修复前只有第一帧,红)
  • break 到达时 buffer 空但流已开:同样补 state=10 收尾;break 后新段未开则不多发

修复前两例红、修复后 10/10 绿;pre-commit(ruff check / ruff format / pyupgrade)通过。未做真机 QQ 端验证,覆盖边界为单测级帧序列断言。

Summary by Sourcery

Ensure QQ C2C streaming segments reliably terminate with the required closing frame.

Bug Fixes:

  • Ensure QQ C2C streaming always sends the required state=10 closing frame for open segments, including when the final buffer is empty or contains only empty text.
  • Close open streaming segments consistently when tool-call breaks occur, while avoiding frames for segments that never started.

Tests:

  • Add regression coverage for empty-tail, tool-call break, and empty-Plain streaming termination scenarios.

QQ's C2C streaming protocol ends a stream with a state=10 frame whose
content must end with \n. AstrBot only sent it while unsent text
remained in the buffer: once the throttled middle frames had flushed
the full reply and the generator ended with an empty tail, no closing
frame went out at all. QQ then timed the stream out and rolled the
whole message back to the first packet, which is why a full 850-char
reply ended up displaying only its first few characters (AstrBotDevs#10066), and
why some streams hang in "generating" forever.

Close every open segment: when the stream is open (a frame id exists)
but the tail buffer is empty, send a minimal "\n" closing frame; when
nothing was ever sent (no id), keep sending nothing. The tool_call
break path closes the same way through a shared helper so an empty
segment cannot orphan its stream either.

Regression tests pin both paths against a fake _post_send: empty tail
after a middle flush, and break arriving on an empty but open segment.

@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 1 issue

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="227-232" />
<code_context>
+        否则客户端等不到结束帧,最终只显示首包几个字。
+        """
+        stream_payload["state"] = 10
+        if not self.send_buffer or not self.send_buffer.chain:
+            if stream_payload.get("id") is None:
+                # 从未发出任何分片,无流可收
+                return None
+            self.send_buffer = MessageChain(chain=[Plain(text="\n")])
+        return await self._post_send(stream=stream_payload)
+
     async def send_streaming(self, generator, use_fallback: bool = False):
</code_context>
<issue_to_address>
**issue (bug_risk):** A buffer containing an empty `Plain` component is treated as non-empty because `self.send_buffer.chain` is truthy, so the helper does not install the `"\n"` fallback. `_post_send_one` then rejects the empty parsed content and no `state=10` frame is sent for an otherwise opened stream.

**Triggers:** When a streaming generator yields an empty text delta represented as `MessageChain(chain=[Plain("")])` after the stream has opened.

**Suggested fix:** Determine whether the buffer contains sendable text rather than only checking whether its component list is non-empty, and replace an effectively empty buffer with `Plain("\n")` before posting the closing frame.
</issue_to_address>

Sourcery assessment

Approval pending. 1 finding to address first.

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


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

Comment thread astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py Outdated
@w31r4

w31r4 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

QQ C2C 流式协议要求以 state=10 分片收尾(content 以 \n 结尾)。

我想知道这个 QQ C2C 流式协议的官方文档在哪里。你有完整的协议文档吗?还是经验总结?

@piexian

piexian commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

QQ C2C 流式协议要求以 state=10 分片收尾(content 以 \n 结尾)。

我想知道这个 QQ C2C 流式协议的官方文档在哪里。你有完整的协议文档吗?还是经验总结?

不出意外我猜是这个https://bot.q.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_openid_stream_messages.post.html

…rame

Review on AstrBotDevs#10069: a buffer holding only Plain("") components has a
truthy chain, so the "\n" fallback was skipped, _post_send_one rejected
the empty parsed content, and no state=10 frame went out for an opened
stream. Check for sendable content (non-empty text or a non-Plain
component) instead of chain non-emptiness. Regression test pins the
empty-Plain tail after a middle flush.
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Fixed in 1db444c, with a regression test that fails without the change.

The finding was right: a buffer holding only Plain("") has a truthy chain, so the "\n" fallback was skipped and _post_send_one rejected the empty content, skipping the state=10 frame for an opened stream. _close_stream_segment now checks for sendable content (a non-empty Plain or any non-Plain component) instead of chain non-emptiness, so the empty-Plain tail after a middle flush still closes with the minimal "\n" frame.

Test: test_c2c_stream_closes_when_tail_is_empty_plain — middle flush takes the full text, the generator ends with Plain(""), and the frame sequence is (state=1, full text) + (state=10, "\n"). Red without the fix, green with it; file's 11 tests all pass, ruff check/format/pyupgrade clean.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

@w31r4 @piexian piexian 猜的链接是对的:官方文档在流式发送单聊消息

文档明确的协议面:input_state 1=生成中、10=生成结束;stream_msg_id 首片由服务端返回、后续分片续传;index 从 0 递增。代码里的 state 就是这个 input_state

要分清楚两条来源:state=1/10 的分片语义是文档写明的;而「state=10 的 content 必须以 \n 结尾」在文档里没有写,那条来自适配器既有代码的注释(发送函数里的 API 说明注记),是实战踩出来的经验。本 PR 只依赖文档写明的部分(缺 state=10 结束帧导致超时回滚),\n 收尾沿用的是代码里已生效的既有约定,没有新造协议要求。

@w31r4 w31r4 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.

LGTM

@w31r4
w31r4 merged commit 31d7989 into AstrBotDevs:master Sep 14, 2026
23 checks passed
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.

[Bug] QQ 官方机器人 C2C 流式回复:内容先变完整后被回滚成首包几个字(缺少 state=10 收尾帧)

3 participants