diff --git a/plugins/Runner/LocalAgent/README.md b/plugins/Runner/LocalAgent/README.md
index 4214ad578..fae574ec5 100644
--- a/plugins/Runner/LocalAgent/README.md
+++ b/plugins/Runner/LocalAgent/README.md
@@ -6,6 +6,10 @@ Local Agent 是 LangBot 官方的进程内运行器。它在 LangBot 插件进
`plugin:langbot-team/LocalAgent/default`
+## SDK 兼容性
+
+LocalAgent 0.2.3 使用 `langbot-plugin>=0.7.18,<0.8.0`,允许同一 0.7 系列的稳定补丁更新,不再精确锁定单个 SDK 版本。测试环境的 `uv.lock` 仍固定已验证版本以保证可复现;插件包的 `requirements.txt` 与项目声明使用相同的兼容范围。跨 0.8 系列升级需要单独验证。
+
## 主要能力
- 支持主模型与 fallback 模型切换。
diff --git a/plugins/Runner/LocalAgent/manifest.yaml b/plugins/Runner/LocalAgent/manifest.yaml
index 9f114e15a..4079c78b6 100644
--- a/plugins/Runner/LocalAgent/manifest.yaml
+++ b/plugins/Runner/LocalAgent/manifest.yaml
@@ -3,7 +3,7 @@ kind: Plugin
metadata:
author: langbot-team
name: LocalAgent
- version: 0.2.2
+ version: 0.2.3
repository: https://github.com/langbot-app/LangBot/tree/master/plugins/Runner/LocalAgent
label:
en_US: Local Agent
diff --git a/plugins/Runner/LocalAgent/pyproject.toml b/plugins/Runner/LocalAgent/pyproject.toml
index 1d93f02a7..67c1041e0 100644
--- a/plugins/Runner/LocalAgent/pyproject.toml
+++ b/plugins/Runner/LocalAgent/pyproject.toml
@@ -1,11 +1,11 @@
[project]
name = "langbot-local-agent"
-version = "0.2.2"
+version = "0.2.3"
description = "Built-in agent with model fallback, tools, and knowledge retrieval"
requires-python = ">=3.11,<4.0"
dependencies = [
"pydantic>=2.0.0",
- "langbot-plugin==0.7.18",
+ "langbot-plugin>=0.7.18,<0.8.0",
]
[project.optional-dependencies]
diff --git a/plugins/Runner/LocalAgent/requirements.txt b/plugins/Runner/LocalAgent/requirements.txt
index e72c24809..1130156db 100644
--- a/plugins/Runner/LocalAgent/requirements.txt
+++ b/plugins/Runner/LocalAgent/requirements.txt
@@ -1,2 +1,2 @@
-langbot-plugin==0.7.18
+langbot-plugin>=0.7.18,<0.8.0
pydantic>=2.0.0
diff --git a/plugins/Runner/LocalAgent/tests/test_sdk_dependency_contract.py b/plugins/Runner/LocalAgent/tests/test_sdk_dependency_contract.py
new file mode 100644
index 000000000..b7a5cd282
--- /dev/null
+++ b/plugins/Runner/LocalAgent/tests/test_sdk_dependency_contract.py
@@ -0,0 +1,39 @@
+import tomllib
+from pathlib import Path
+
+import pytest
+import yaml
+from packaging.requirements import Requirement
+
+PLUGIN_ROOT = Path(__file__).resolve().parents[1]
+
+
+def _sdk_requirements():
+ project = tomllib.loads((PLUGIN_ROOT / "pyproject.toml").read_text())
+ packaged = (PLUGIN_ROOT / "requirements.txt").read_text().splitlines()
+ return [
+ next(Requirement(line) for line in requirements if line.startswith("langbot-plugin"))
+ for requirements in (project["project"]["dependencies"], packaged)
+ ]
+
+
+def test_project_and_packaged_sdk_constraints_match():
+ project, packaged = _sdk_requirements()
+ assert project.specifier == packaged.specifier
+ assert str(project.specifier) == "<0.8.0,>=0.7.18"
+
+
+@pytest.mark.parametrize("version", ["0.7.18", "0.7.19", "0.7.99"])
+def test_sdk_constraints_accept_compatible_stable_patches(version):
+ assert all(requirement.specifier.contains(version) for requirement in _sdk_requirements())
+
+
+@pytest.mark.parametrize("version", ["0.7.16", "0.7.17", "0.8.0", "1.0.0"])
+def test_sdk_constraints_reject_unverified_versions(version):
+ assert not any(requirement.specifier.contains(version) for requirement in _sdk_requirements())
+
+
+def test_manifest_and_project_versions_match():
+ project = tomllib.loads((PLUGIN_ROOT / "pyproject.toml").read_text())
+ manifest = yaml.safe_load((PLUGIN_ROOT / "manifest.yaml").read_text())
+ assert project["project"]["version"] == manifest["metadata"]["version"]
diff --git a/plugins/Runner/LocalAgent/uv.lock b/plugins/Runner/LocalAgent/uv.lock
index e3537f13e..4163b0818 100644
--- a/plugins/Runner/LocalAgent/uv.lock
+++ b/plugins/Runner/LocalAgent/uv.lock
@@ -456,7 +456,7 @@ wheels = [
[[package]]
name = "langbot-local-agent"
-version = "0.2.2"
+version = "0.2.3"
source = { editable = "." }
dependencies = [
{ name = "langbot-plugin" },
@@ -472,7 +472,7 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "langbot-plugin", specifier = "==0.7.18" },
+ { name = "langbot-plugin", specifier = ">=0.7.18,<0.8.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
diff --git a/plugins/catalog.json b/plugins/catalog.json
index 5c1621731..d829d2b0c 100644
--- a/plugins/catalog.json
+++ b/plugins/catalog.json
@@ -36,7 +36,7 @@
},
{
"id": "langbot-team/LocalAgent",
- "version": "0.2.2",
+ "version": "0.2.3",
"category": "Runner",
"path": "Runner/LocalAgent",
"repository": "https://github.com/langbot-app/LangBot/tree/master/plugins/Runner/LocalAgent"
diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py
index 4fedae5ca..5dce0ce9a 100644
--- a/src/langbot/libs/qq_official_api/api.py
+++ b/src/langbot/libs/qq_official_api/api.py
@@ -401,13 +401,21 @@ class QQOfficialClient:
content_type = attachment.get('content_type', '')
return content_type.startswith('image/')
+ def _resolve_msg_seq(self, msg_seq: Optional[int] = None) -> int:
+ """Allocate automatic sequences across message types, preserving explicit stream sequences."""
+ if msg_seq is None:
+ self._msg_seq_counter += 1
+ return self._msg_seq_counter
+ self._msg_seq_counter = max(self._msg_seq_counter, msg_seq)
+ return msg_seq
+
async def send_private_text_msg(
self,
user_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
- msg_seq: int = 1,
+ msg_seq: Optional[int] = None,
):
"""Send a c2c text message.
@@ -427,7 +435,7 @@ class QQOfficialClient:
data: dict[str, Any] = {
'content': content,
'msg_type': 0,
- 'msg_seq': msg_seq,
+ 'msg_seq': self._resolve_msg_seq(msg_seq),
}
if msg_id:
data['msg_id'] = msg_id
@@ -448,7 +456,7 @@ class QQOfficialClient:
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
- msg_seq: int = 1,
+ msg_seq: Optional[int] = None,
) -> None:
"""Send a Markdown message to a C2C user or QQ group."""
if not await self.check_access_token():
@@ -464,7 +472,7 @@ class QQOfficialClient:
data: dict[str, Any] = {
'msg_type': 2,
'markdown': {'content': content},
- 'msg_seq': msg_seq,
+ 'msg_seq': self._resolve_msg_seq(msg_seq),
}
if msg_id:
data['msg_id'] = msg_id
@@ -488,7 +496,7 @@ class QQOfficialClient:
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
- msg_seq: int = 1,
+ msg_seq: Optional[int] = None,
) -> None:
"""Send a Markdown C2C message."""
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
@@ -499,7 +507,7 @@ class QQOfficialClient:
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
- msg_seq: int = 1,
+ msg_seq: Optional[int] = None,
) -> None:
"""Send a Markdown QQ group message."""
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
@@ -510,7 +518,7 @@ class QQOfficialClient:
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
- msg_seq: int = 1,
+ msg_seq: Optional[int] = None,
):
"""Send a group text message.
@@ -529,7 +537,7 @@ class QQOfficialClient:
data: dict[str, Any] = {
'content': content,
'msg_type': 0,
- 'msg_seq': msg_seq,
+ 'msg_seq': self._resolve_msg_seq(msg_seq),
}
if msg_id:
data['msg_id'] = msg_id
@@ -685,12 +693,10 @@ class QQOfficialClient:
else:
raise ValueError(f'Unsupported target_type: {target_type}')
- self._msg_seq_counter += 1
- msg_seq = self._msg_seq_counter
body = {
'msg_type': 7,
'media': {'file_info': file_info},
- 'msg_seq': msg_seq,
+ 'msg_seq': self._resolve_msg_seq(),
}
if content:
body['content'] = content
@@ -792,7 +798,7 @@ class QQOfficialClient:
'content_raw': content,
'event_id': event_id,
'msg_id': msg_id,
- 'msg_seq': msg_seq,
+ 'msg_seq': self._resolve_msg_seq(msg_seq),
'index': index,
}
if stream_msg_id:
@@ -817,7 +823,7 @@ class QQOfficialClient:
keyboard: Optional[dict] = None,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
- msg_seq: int = 1,
+ msg_seq: Optional[int] = None,
) -> dict:
"""Send a ``msg_type=2`` (markdown) message carrying a keyboard.
@@ -838,7 +844,7 @@ class QQOfficialClient:
event_id: Use ``INTERACTION_CREATE`` event id from a prior
button click to keep within the 30-minute passive window
without an inbound msg_id.
- msg_seq: De-dup counter when reusing msg_id.
+ msg_seq: Explicit de-dup counter, or None to allocate automatically.
"""
if not await self.check_access_token():
await self.get_access_token()
@@ -853,7 +859,7 @@ class QQOfficialClient:
body: dict[str, Any] = {
'msg_type': 2,
'markdown': {'content': markdown_content},
- 'msg_seq': msg_seq,
+ 'msg_seq': self._resolve_msg_seq(msg_seq),
}
if keyboard and keyboard.get('content', {}).get('rows'):
body['keyboard'] = keyboard
diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py
index d8dcad0c9..6a3fb351a 100644
--- a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py
+++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py
@@ -139,7 +139,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
raw = await self._send_content_list(
- str(target_type), str(target_id), await QQOfficialMessageConverter.yiri2target(message)
+ str(target_type),
+ str(target_id),
+ await QQOfficialMessageConverter.yiri2target(message, self._normalize_target_type(str(target_type))),
)
return platform_events.MessageResult(raw={'results': raw})
@@ -156,7 +158,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
raw = await self._send_content_list(
target_type,
target_id,
- await QQOfficialMessageConverter.yiri2target(message),
+ await QQOfficialMessageConverter.yiri2target(message, self._normalize_target_type(target_type)),
msg_id=source.d_id,
)
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
@@ -177,7 +179,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
*,
msg_id: typing.Optional[str] = None,
event_id: typing.Optional[str] = None,
- msg_seq: int = 1,
+ msg_seq: typing.Optional[int] = None,
) -> typing.Any:
"""Send a text reply using the configured C2C/group render mode."""
use_markdown = self.config.get('enable-markdown-rendering', False)
diff --git a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py
index 545046f1a..ae3c935ab 100644
--- a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py
+++ b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import datetime
+import html
import re
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
@@ -21,18 +22,35 @@ def _is_base64_data(value: str) -> bool:
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
- async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
+ async def yiri2target(message_chain: platform_message.MessageChain, target_type: str | None = None) -> list[dict]:
content_list: list[dict] = []
+ text_parts: list[str] = []
+
+ def flush_text() -> None:
+ content = ''.join(text_parts)
+ if content:
+ content_list.append({'type': 'text', 'content': content})
+ text_parts.clear()
+
for component in message_chain:
if isinstance(component, platform_message.Source):
continue
if isinstance(component, platform_message.Plain):
- content_list.append({'type': 'text', 'content': component.text})
+ text_parts.append(component.text)
+ continue
elif isinstance(component, platform_message.At):
- content_list.append({'type': 'text', 'content': f'@{component.display or component.target}'})
+ if target_type in {None, 'group', 'channel'}:
+ target = html.escape(str(component.target), quote=True)
+ text_parts.append(f'')
+ else:
+ text_parts.append(f'@{component.display or component.target}')
+ continue
elif isinstance(component, platform_message.AtAll):
- content_list.append({'type': 'text', 'content': '@all'})
- elif isinstance(component, platform_message.Image):
+ text_parts.append('' if target_type == 'channel' else '@all')
+ continue
+
+ flush_text()
+ if isinstance(component, platform_message.Image):
content_list.append(QQOfficialMessageConverter._media_payload(component, 'image'))
elif isinstance(component, platform_message.Voice):
content_list.append(QQOfficialMessageConverter._media_payload(component, 'voice'))
@@ -44,15 +62,18 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
if component.id is not None:
content_list.append({'type': 'text', 'content': f'[Quote {component.id}]'})
if component.origin:
- content_list.extend(await QQOfficialMessageConverter.yiri2target(component.origin))
+ content_list.extend(await QQOfficialMessageConverter.yiri2target(component.origin, target_type))
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
- content_list.extend(await QQOfficialMessageConverter.yiri2target(node.message_chain))
+ content_list.extend(
+ await QQOfficialMessageConverter.yiri2target(node.message_chain, target_type)
+ )
else:
text = str(component)
if text:
content_list.append({'type': 'text', 'content': text})
+ flush_text()
return content_list
@staticmethod
diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py
index fcc4c1a93..69c513ae1 100644
--- a/src/langbot/pkg/platform/sources/qqofficial.py
+++ b/src/langbot/pkg/platform/sources/qqofficial.py
@@ -387,7 +387,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
*,
msg_id: typing.Optional[str] = None,
event_id: typing.Optional[str] = None,
- msg_seq: int = 1,
+ msg_seq: typing.Optional[int] = None,
) -> None:
"""Send a text reply using the configured C2C/group render mode."""
use_markdown = self.config.get('enable-markdown-rendering', False)
diff --git a/tests/fixtures/legacy_plugin_identity_sources.json b/tests/fixtures/legacy_plugin_identity_sources.json
index 89e22e7c8..da8609482 100644
--- a/tests/fixtures/legacy_plugin_identity_sources.json
+++ b/tests/fixtures/legacy_plugin_identity_sources.json
@@ -21,7 +21,7 @@
"Runner/LocalAgent/components/runner/default.py": "49f80640d5b26442516b210cd9e65c452c9ce3a9909d6dbd1aa05e5bf3b232ac",
"Runner/LocalAgent/components/runner/default.yaml": "c1364cf0f211162bb701e27ed92b351fd9ff4cfa0b4469cfeb83ad58136cdf47",
"Runner/LocalAgent/main.py": "0cde45d7f6f411563fbded09be70d0a497c7c7999b5eaa27f83052383348277f",
- "Runner/LocalAgent/manifest.yaml": "bc04121a6ffe73fee06c70634726216a6c45b656a350fdab7807d06403cad2cf",
+ "Runner/LocalAgent/manifest.yaml": "d4ccb50d83e83e452ab0b14822f245d5dc4bb85c7bc963ee467f299d4723063f",
"Runner/LocalAgent/pkg/__init__.py": "ce5142b4971e8d7945fb3c202bacbc28011b033de0ddcb612d5d29106d6985ec",
"Runner/LocalAgent/pkg/agent_core/__init__.py": "56e95f2391f796e17aee6a168603853ea78b59df97a136bfa33532aede2fa413",
"Runner/LocalAgent/pkg/agent_core/langbot.py": "6bc0e6ee89e8ba3705d14545169f25f54a0c56dc5da89bd8384b3e22e3f21fea",
@@ -34,9 +34,9 @@
"Runner/LocalAgent/pkg/model_calling.py": "3ebdb8900bb0de5b14e84bf027cc4ff01e59a4669d28dcede7ca3dcea3a3de4c",
"Runner/LocalAgent/pkg/rag.py": "ea407af8d4f6fe5b00cde72c7d85ccc541d6fc46254188c372351d93da4a1f0f",
"Runner/LocalAgent/pkg/run_assembly.py": "e1e2dd0029c976b6c0eb3763e5f94a369fd1909e8bf796a2cb84658c59727630",
- "Runner/LocalAgent/pyproject.toml": "7f75c366ef0c6817c84cc906c9cd2783df9782876983ae6b15cb299a27409e62",
- "Runner/LocalAgent/requirements.txt": "5c91c75dba82f776218d1e63541df3d2f444bb1f9354ea991826df57dad401c9",
- "Runner/LocalAgent/uv.lock": "fc82991d3b2fadff999198c3c1ab35fc04eb8f64aa3f1f464aa93c87afa5ae01"
+ "Runner/LocalAgent/pyproject.toml": "18ec79e6bf40cca8d7b49df93489e2b3d9d74d820ac2f2032fb86d99b0b3cec4",
+ "Runner/LocalAgent/requirements.txt": "7ae9e2f4a957346c938f46dca3769c386c57cc4bc9044fd342358c0163d82bc4",
+ "Runner/LocalAgent/uv.lock": "6ee65461bcff71e93e0701ec91d56f298449144f708d83dd7dde3b1b8f548d05"
},
"root": "plugins"
}
diff --git a/tests/unit_tests/platform/test_qqofficial_api.py b/tests/unit_tests/platform/test_qqofficial_api.py
index c0dfdf84c..7bec61a5a 100644
--- a/tests/unit_tests/platform/test_qqofficial_api.py
+++ b/tests/unit_tests/platform/test_qqofficial_api.py
@@ -194,14 +194,14 @@ async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies():
content='# Hello',
msg_id='message-1',
event_id=None,
- msg_seq=1,
+ msg_seq=None,
)
adapter.bot.send_group_markdown_msg.assert_awaited_once_with(
group_openid='group-1',
content='* Hello',
msg_id=None,
event_id='event-1',
- msg_seq=1,
+ msg_seq=None,
)
adapter.bot.send_private_text_msg.assert_not_awaited()
adapter.bot.send_group_text_msg.assert_not_awaited()
diff --git a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py
index a5aee2934..8a0aecec7 100644
--- a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py
+++ b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py
@@ -243,9 +243,7 @@ async def test_qqofficial_message_converter_maps_common_components_to_send_paylo
)
)
- assert {'type': 'text', 'content': 'hi'} in payload
- assert {'type': 'text', 'content': '@Alice'} in payload
- assert {'type': 'text', 'content': '@all'} in payload
+ assert {'type': 'text', 'content': 'hi@all'} in payload
assert any(item['type'] == 'image' and item['url'] == 'https://example.test/a.png' for item in payload)
assert any(item['type'] == 'voice' and item['base64'].startswith('data:audio') for item in payload)
assert any(item['type'] == 'file' and item['name'] == 'a.txt' for item in payload)
diff --git a/tests/unit_tests/platform/test_qqofficial_message_sequences.py b/tests/unit_tests/platform/test_qqofficial_message_sequences.py
new file mode 100644
index 000000000..a46a0ab12
--- /dev/null
+++ b/tests/unit_tests/platform/test_qqofficial_message_sequences.py
@@ -0,0 +1,223 @@
+import asyncio
+import json
+import time
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import httpx
+import pytest
+import pytest_asyncio
+
+import langbot.pkg.core.app # noqa: F401
+from langbot.libs.qq_official_api.api import QQOfficialClient
+from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
+from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
+from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter
+from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
+from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter as LegacyQQOfficialAdapter
+from langbot_plugin.api.entities.builtin.platform import message
+
+
+@pytest_asyncio.fixture
+async def captured_client():
+ requests = []
+
+ async def respond(request):
+ await asyncio.sleep(0)
+ requests.append((request.url.path, json.loads(request.content)))
+ return httpx.Response(200, json={'id': 'reply', 'file_info': 'uploaded-media'})
+
+ client = QQOfficialClient('fixture-secret', '', 'fixture-app', AsyncMock())
+ client.access_token = 'fixture-token'
+ client.access_token_expiry_time = time.time() + 3600
+ for timeout in (None, 30, 120):
+ client._http_clients[timeout] = httpx.AsyncClient(transport=httpx.MockTransport(respond))
+ try:
+ yield client, requests
+ finally:
+ await client.close()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('adapter_class', [QQOfficialAdapter, LegacyQQOfficialAdapter])
+@pytest.mark.parametrize('markdown', [False, True])
+@pytest.mark.parametrize('event_type', ['C2C_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'])
+async def test_segmented_replies_share_sequences(captured_client, adapter_class, markdown, event_type):
+ client, requests = captured_client
+ adapter = adapter_class.model_construct(
+ bot=client, config={'enable-markdown-rendering': markdown}, logger=AsyncMock()
+ )
+ source = SimpleNamespace(
+ source_platform_object=QQOfficialEvent(
+ {'t': event_type, 'd_id': 'source-one', 'user_openid': 'user-one', 'group_openid': 'group-one'}
+ )
+ )
+ await adapter.reply_message(
+ source,
+ message.MessageChain(
+ [message.At(target='user-one'), message.Plain(text='first'), message.Plain(text='second')]
+ ),
+ )
+ await adapter.reply_message(source, message.MessageChain([message.Plain(text='follow-up')]))
+
+ payloads = [body for _, body in requests]
+ expected_sequences = [1, 2] if adapter_class is QQOfficialAdapter else [1, 2, 3]
+ assert [body['msg_seq'] for body in payloads] == expected_sequences
+ assert all(body['msg_id'] == 'source-one' for body in payloads)
+ assert all(body['msg_type'] == (2 if markdown else 0) for body in payloads)
+ expected_path = (
+ '/v2/users/user-one/messages' if event_type == 'C2C_MESSAGE_CREATE' else '/v2/groups/group-one/messages'
+ )
+ assert all(path == expected_path for path, _ in requests)
+ texts = [body['markdown']['content'] if markdown else body['content'] for body in payloads]
+ if adapter_class is QQOfficialAdapter:
+ mention = '@user-one' if event_type == 'C2C_MESSAGE_CREATE' else ''
+ assert texts == [f'{mention}firstsecond', 'follow-up']
+ else:
+ assert texts == ['first', 'second', 'follow-up']
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('markdown', [False, True])
+@pytest.mark.parametrize('event_type', ['GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'])
+async def test_group_reply_mentions_original_sender_in_one_message(captured_client, markdown, event_type):
+ client, requests = captured_client
+ adapter = QQOfficialAdapter.model_construct(
+ bot=client, config={'enable-markdown-rendering': markdown}, logger=AsyncMock()
+ )
+ source = await QQOfficialEventConverter().target2yiri(
+ QQOfficialEvent(
+ {
+ 't': event_type,
+ 'd_id': 'source-one',
+ 'member_openid': 'sender-openid',
+ 'group_openid': 'group-one',
+ 'content': 'hello',
+ 'timestamp': '2026-10-28T10:00:00+0000',
+ }
+ )
+ )
+ await adapter.reply_message(
+ source,
+ message.MessageChain(
+ [
+ message.At(target=source.sender.id, display='Alice'),
+ message.Plain(text=' hello'),
+ message.Plain(text='!'),
+ ]
+ ),
+ )
+
+ assert len(requests) == 1
+ path, payload = requests[0]
+ assert path == '/v2/groups/group-one/messages'
+ assert payload['msg_id'] == 'source-one'
+ assert payload['msg_seq'] == 1
+ content = payload['markdown']['content'] if markdown else payload['content']
+ assert content == ' hello!'
+
+
+@pytest.mark.asyncio
+async def test_merged_text_preserves_media_order_and_sequences(captured_client):
+ client, requests = captured_client
+ adapter = QQOfficialAdapter.model_construct(bot=client, config={}, logger=AsyncMock())
+ source = SimpleNamespace(
+ source_platform_object=QQOfficialEvent(
+ {'t': 'GROUP_MESSAGE_CREATE', 'd_id': 'source-one', 'group_openid': 'group-one'}
+ )
+ )
+ await adapter.reply_message(
+ source,
+ message.MessageChain(
+ [
+ message.At(target='sender'),
+ message.Plain(text=' image:'),
+ message.Image(url='https://example.test/image.png'),
+ message.Plain(text='after'),
+ message.Plain(text=' image'),
+ ]
+ ),
+ )
+ payloads = [body for path, body in requests if path.endswith('/messages')]
+ assert [body['msg_seq'] for body in payloads] == [1, 2, 3]
+ assert [body['msg_type'] for body in payloads] == [0, 7, 0]
+ assert payloads[0]['content'] == ' image:'
+ assert payloads[2]['content'] == 'after image'
+
+
+@pytest.mark.asyncio
+async def test_mentions_escape_ids_without_using_display_names():
+ payloads = await QQOfficialMessageConverter.yiri2target(
+ message.MessageChain([message.At(target='bad"/>'}]
+
+
+@pytest.mark.asyncio
+async def test_empty_text_does_not_emit_an_empty_message(captured_client):
+ client, requests = captured_client
+ adapter = QQOfficialAdapter.model_construct(bot=client, config={}, logger=AsyncMock())
+ await adapter.send_message('group', 'group-one', message.MessageChain([message.Plain(text='')]))
+ assert requests == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('target_type', ['channel', 'guild'])
+async def test_channel_mentions_are_native_and_not_split(captured_client, target_type):
+ client, requests = captured_client
+ adapter = QQOfficialAdapter.model_construct(bot=client, config={}, logger=AsyncMock())
+ await adapter.send_message(
+ target_type,
+ 'channel-one',
+ message.MessageChain([message.At(target='member'), message.AtAll(), message.Plain(text=' hello')]),
+ )
+ assert len(requests) == 1
+ assert requests[0][1]['content'] == ' hello'
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('target_type', ['c2c', 'group'])
+async def test_text_media_markdown_and_keyboard_share_sequences(captured_client, target_type):
+ client, requests = captured_client
+ text_sender = client.send_private_text_msg if target_type == 'c2c' else client.send_group_text_msg
+ markdown_sender = client.send_private_markdown_msg if target_type == 'c2c' else client.send_group_markdown_msg
+ await text_sender('target', 'first', msg_id='source')
+ await client.send_image_msg(target_type, 'target', file_url='https://example.test/image.png', msg_id='source')
+ await markdown_sender('target', '**second**', msg_id='source')
+ await client.send_markdown_keyboard(target_type, 'target', 'third', {}, msg_id='source')
+ await text_sender('target', 'fourth', msg_id='source')
+
+ payloads = [body for path, body in requests if path.endswith('/messages')]
+ assert [body['msg_seq'] for body in payloads] == [1, 2, 3, 4, 5]
+ assert [body['msg_type'] for body in payloads] == [0, 7, 2, 2, 0]
+ assert all(body['msg_id'] == 'source' for body in payloads)
+
+
+@pytest.mark.asyncio
+async def test_concurrent_replies_do_not_reuse_sequences(captured_client):
+ client, requests = captured_client
+ await asyncio.gather(*(client.send_group_text_msg('group', str(index), msg_id='source') for index in range(20)))
+ assert sorted(body['msg_seq'] for _, body in requests) == list(range(1, 21))
+
+
+@pytest.mark.asyncio
+async def test_explicit_stream_sequence_is_preserved_and_reserved(captured_client):
+ client, requests = captured_client
+ await client.send_stream_msg('user', 'first', event_id='source', msg_id='source', msg_seq=7)
+ await client.send_stream_msg(
+ 'user', 'final', event_id='source', msg_id='source', msg_seq=7, stream_msg_id='reply', input_state=10
+ )
+ await client.send_private_text_msg('user', 'follow-up', msg_id='source')
+ await client.send_private_markdown_msg('user', '**explicit**', msg_id='source', msg_seq=10)
+ await client.send_private_text_msg('user', 'automatic', msg_id='source')
+ assert [body['msg_seq'] for _, body in requests] == [7, 7, 8, 10, 11]
+
+
+@pytest.mark.asyncio
+async def test_failed_send_does_not_reuse_sequence(captured_client):
+ client, requests = captured_client
+ client._http_clients[None].post = AsyncMock(side_effect=httpx.ReadTimeout('response lost'))
+ with pytest.raises(httpx.ReadTimeout):
+ await client.send_group_text_msg('group', 'first', msg_id='source')
+ await client.send_markdown_keyboard('group', 'group', 'retry', {}, msg_id='source')
+ assert requests[0][1]['msg_seq'] == 2