Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	app/api/endpoints/agent.py
#	app/api/endpoints/anthropic.py
#	app/api/endpoints/openai.py
#	app/chain/__init__.py
#	app/chain/message.py
#	app/chain/site.py
#	app/chain/subscribe.py
#	app/chain/transfer.py
#	app/modules/discord/__init__.py
#	app/modules/qqbot/__init__.py
#	app/modules/slack/__init__.py
#	app/modules/telegram/__init__.py
#	app/modules/wechat/__init__.py
#	app/runtime/extensions/module_manager.py
#	app/runtime/extensions/service_registry.py
#	tests/test_agent_interaction.py
#	tests/test_slash_command_interactions.py
#	tests/test_web_agent_stream.py
This commit is contained in:
jxxghp
2026-08-16 19:44:43 +08:00
232 changed files with 21375 additions and 6683 deletions
+66 -11
View File
@@ -6,10 +6,11 @@ from threading import Event as ThreadEvent
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app import schemas
from app.agent import ReplyMode, agent_manager
from app.api.endpoints.agent import (
_WebAgentMoviePilotAgent,
_WebAgentEventPublisher,
_WEB_AGENT_FILE_REGISTRY,
_WEB_AGENT_MESSAGE_QUEUES,
@@ -23,6 +24,7 @@ from app.api.endpoints.agent import (
_collect_web_agent_traditional_events,
_dispatch_web_agent_message_event,
_extract_web_agent_message_from_event_data,
_get_web_agent_type,
_has_web_agent_traditional_interaction,
_prepare_web_agent_audio_attachment_path,
_transcribe_web_agent_audio_refs,
@@ -41,6 +43,26 @@ from app.schemas.notification import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import EventType, NotificationChannel, MessageType
@pytest.fixture(autouse=True)
def _running_agent_service():
"""本文件验证运行态 Web Agent 行为,显式提供已启动的 canonical manager。"""
was_accepting = agent_manager._accepting_tasks
agent_manager._accepting_tasks = True
MessageChain._user_sessions.clear()
try:
with patch(
"app.api.endpoints.agent.get_running_agent_manager",
return_value=agent_manager,
), patch(
"app.chain.message.get_running_agent_manager",
return_value=agent_manager,
):
yield
finally:
MessageChain._user_sessions.clear()
agent_manager._accepting_tasks = was_accepting
def test_split_web_agent_output_extracts_verbose_tool_message():
"""应将啰嗦模式工具提示拆成独立工具事件,并保留渠道展示文案。"""
events = _split_web_agent_output("准备查询。\n\n⚙️ => 查询站点\n\n已完成")
@@ -358,7 +380,7 @@ def test_has_web_agent_traditional_interaction_detects_pending_skills():
def test_web_agent_admin_context_uses_current_user_id():
"""Web Agent 工具权限应按当前登录用户 ID 判断管理员身份。"""
agent = _WebAgentMoviePilotAgent(
agent = _get_web_agent_type()(
session_id="web-agent:session",
user_id="7",
channel=NotificationChannel.WebAgent.value,
@@ -378,7 +400,7 @@ def test_web_agent_admin_context_uses_current_user_id():
def test_web_agent_reused_for_background_task_disables_streaming():
"""Web Agent 被后台任务复用且渠道已清空时应改用非流式广播。"""
agent = _WebAgentMoviePilotAgent(
agent = _get_web_agent_type()(
session_id="web-agent:scheduled-session",
user_id="7",
channel=None,
@@ -394,7 +416,7 @@ def test_web_agent_reused_for_background_task_disables_streaming():
def test_web_agent_output_callback_receives_only_new_text():
"""WebAgent 外部回调应接收增量,同时内部仍保留完整输出。"""
outputs = []
agent = _WebAgentMoviePilotAgent(
agent = _get_web_agent_type()(
session_id="web-agent:incremental-output",
user_id="7",
channel=NotificationChannel.WebAgent.value,
@@ -414,7 +436,7 @@ def test_web_agent_output_callback_receives_only_new_text():
def test_web_agent_tool_summary_is_emitted_before_following_text():
"""Web 工具状态应在调用发生时输出,不能拖到正文结束后。"""
outputs = []
agent = _WebAgentMoviePilotAgent(
agent = _get_web_agent_type()(
session_id="web-agent:tool-order",
user_id="7",
channel=NotificationChannel.WebAgent.value,
@@ -716,8 +738,8 @@ def test_web_agent_stream_binds_session_to_agent_manager():
try:
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
"app.api.endpoints.agent._WebAgentMoviePilotAgent",
FakeWebAgent,
"app.api.endpoints.agent._get_web_agent_type",
return_value=FakeWebAgent,
):
body = asyncio.run(scenario())
@@ -812,8 +834,8 @@ def test_web_agent_stream_emits_secret_result_only_as_protected_event():
try:
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
"app.api.endpoints.agent._WebAgentMoviePilotAgent",
FakeProtectedAgent,
"app.api.endpoints.agent._get_web_agent_type",
return_value=FakeProtectedAgent,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
) as save_snapshot:
@@ -1128,8 +1150,8 @@ def test_web_agent_stop_finishes_stream_without_error():
MessageChain,
"bind_user_session",
), patch(
"app.api.endpoints.agent._WebAgentMoviePilotAgent",
BlockingWebAgent,
"app.api.endpoints.agent._get_web_agent_type",
return_value=BlockingWebAgent,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
):
@@ -1143,6 +1165,39 @@ def test_web_agent_stop_finishes_stream_without_error():
assert '"type": "error"' not in body
def test_web_agent_stream_rechecks_running_service_before_enqueue():
"""响应建立后服务若已关闭,生成器必须稳定返回错误且不向旧 manager 入队。"""
payload = schemas.AgentWebChatRequest(
text="检查状态",
session_id="shutdown-race",
)
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
stale_manager = SimpleNamespace(process_message=AsyncMock())
async def scenario():
response = await web_agent_stream(payload, request, user)
return "".join(await _collect_streaming_response(response))
with patch("app.api.endpoints.agent.settings.AI_AGENT_ENABLE", True), patch(
"app.api.endpoints.agent._is_web_agent_traditional_message",
return_value=False,
), patch(
"app.api.endpoints.agent._has_web_agent_traditional_interaction",
return_value=False,
), patch(
"app.api.endpoints.agent.get_running_agent_manager",
side_effect=[stale_manager, None],
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
):
body = asyncio.run(scenario())
assert '"type": "error"' in body
assert '"type": "done"' in body
stale_manager.process_message.assert_not_awaited()
def test_web_agent_traditional_stream_keeps_alive_and_saves_after_done():
"""传统消息等待期间应保活,且展示快照不能阻塞终态。"""
payload = schemas.AgentWebChatRequest(text="/状态", session_id="traditional-heartbeat")