Files
MoviePilot/tests/test_web_agent_stream.py
T

1921 lines
69 KiB
Python

import asyncio
import time
from queue import Queue
from threading import Event as ThreadEvent
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app import schemas
from app.agent.contracts import ReplyMode
from app.agent.orchestrator import agent_manager
from app.api.endpoints.agent import (
_WEB_AGENT_FILE_REGISTRY,
_apply_web_agent_display_event,
_build_web_agent_command_items,
_build_web_agent_display_message_from_events,
_build_web_agent_input_attachments,
_build_web_agent_message_events,
_build_web_agent_session_id,
_build_web_agent_session_id_async,
_build_web_agent_traditional_callback_payload,
_collect_web_agent_traditional_events,
_get_web_agent_type,
_has_web_agent_traditional_interaction,
_prepare_web_agent_audio_attachment_path_async,
_resolve_web_agent_audio_refs,
_resolve_web_agent_choice_payload,
_split_web_agent_output,
_transcribe_web_agent_audio_files,
_WebAgentEventPublisher,
web_agent_stream,
)
from app.application.messaging.agent import (
AgentInteractionOption,
agent_interaction_manager,
attach_web_agent_message_queue,
build_web_agent_message_update_event,
detach_web_agent_message_queue,
dispatch_web_agent_message_event,
extract_web_agent_message_from_event_data,
wait_web_agent_background_tasks,
)
from app.application.messaging.chat import AgentChatService, configure_agent_chat_service
from app.application.messaging.skill import skill_interaction_manager
from app.chain.message import MessageChain
from app.db.oper.agentchat import AgentChatOper
from app.runtime.events import Event
from app.schemas.notification import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import EventType, MessageType, NotificationChannel
@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已完成")
assert events == [
{"type": "delta", "content": "准备查询。\n\n"},
{"type": "tool", "message": "⚙️ => 查询站点"},
{"type": "delta", "content": "已完成"},
]
def test_split_web_agent_output_extracts_summary_tool_message():
"""应将非啰嗦模式工具汇总行拆成独立工具事件,并保留渠道展示文案。"""
events = _split_web_agent_output("(查询了 2 次数据)\n\n这里是结果")
assert events == [
{"type": "tool", "message": "(查询了 2 次数据)"},
{"type": "delta", "content": "\n这里是结果"},
]
def test_split_web_agent_output_preserves_standalone_newline_delta():
"""独立换行增量应保留,避免流式 Markdown 列表被拼成同一行。"""
chunks = [
"可以这样操作:",
"\n",
"- **搜索资源**:搜索电影",
"\n",
"- **下载管理**:添加任务",
]
content = ""
for chunk in chunks:
for event in _split_web_agent_output(chunk):
if event["type"] == "delta":
content += event["content"]
assert content == "可以这样操作:\n- **搜索资源**:搜索电影\n- **下载管理**:添加任务"
def test_web_agent_event_publisher_coalesces_text_before_semantic_events():
"""连续文本应合并,且工具事件前的文本顺序不能改变。"""
async def scenario():
publisher = _WebAgentEventPublisher()
try:
for index in range(100):
publisher.publish({"type": "delta", "content": str(index % 10)})
publisher.publish({"type": "tool", "message": "查询完成"})
first = await asyncio.wait_for(publisher.get(), timeout=1)
second = await asyncio.wait_for(publisher.get(), timeout=1)
return first, second, publisher.max_depth
finally:
await publisher.aclose()
first, second, max_depth = asyncio.run(scenario())
assert first == {
"type": "delta",
"content": "".join(str(index % 10) for index in range(100)),
}
assert second == {"type": "tool", "message": "查询完成"}
assert max_depth == 2
def test_web_agent_event_publisher_rejects_events_after_close():
"""连接关闭后必须显式拒绝事件,避免把敏感结果误报为已交付。"""
async def scenario():
publisher = _WebAgentEventPublisher()
await publisher.aclose()
return publisher.publish(
{"type": "interaction-protected", "content": "secret"}
)
assert asyncio.run(scenario()) is False
def test_build_web_agent_session_id_is_stable_per_user_and_seed():
"""同一用户和前端会话标识应生成稳定的服务端会话 ID。"""
user = SimpleNamespace(id=1, name="admin")
first = _build_web_agent_session_id(user, "browser-session")
second = _build_web_agent_session_id(user, "browser-session")
other = _build_web_agent_session_id(user, "other-session")
assert first == second
assert first != other
assert first.startswith("web-agent:")
def test_build_web_agent_session_id_reuses_accessible_history():
"""传入已有历史会话 ID 时应直接复用,避免跨渠道继续对话丢上下文。"""
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
AgentChatOper().save_display_messages(
session_id="telegram-session",
user_id="telegram-user",
username="tester",
channel=NotificationChannel.Telegram.value,
source="telegram-main",
messages=[],
title="Telegram 会话",
)
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
assert _build_web_agent_session_id(user, "telegram-session") == "telegram-session"
def test_build_web_agent_session_id_async_uses_native_async_persistence():
"""异步 Web 会话解析应通过 native async 会话服务读取历史。"""
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
service = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
user_id="telegram-user",
username="tester",
agent_messages=[],
)
)
)
with patch(
"app.api.endpoints.agent.get_configured_agent_chat_service",
return_value=service,
):
session_id = asyncio.run(
_build_web_agent_session_id_async(user, "telegram-session")
)
assert session_id == "telegram-session"
service.get.assert_awaited_once_with("telegram-session")
def test_apply_web_agent_display_event_updates_snapshot():
"""WebAgent SSE 事件应按到达顺序聚合为服务端展示快照。"""
message = {
"id": "assistant-1",
"role": "assistant",
"content": "",
"createdAt": 1,
"status": "streaming",
"tools": [],
"segments": [],
"attachments": [],
"choices": [],
}
_apply_web_agent_display_event({"type": "delta", "content": "你好"}, message)
_apply_web_agent_display_event({"type": "tool", "message": "查询订阅"}, message)
_apply_web_agent_display_event({"type": "delta", "content": ",查询完成"}, message)
_apply_web_agent_display_event(
{
"type": "attachment",
"attachment": {"kind": "file", "url": "message/agent/file/a"},
},
message,
)
_apply_web_agent_display_event({"type": "done"}, message)
assert message["content"] == "你好,查询完成"
assert message["status"] == "done"
assert len(message["tools"]) == 1
assert message["tools"][0]["message"] == "查询订阅"
assert message["tools"][0]["status"] == "done"
assert message["segments"] == [
{"type": "text", "content": "你好"},
{"type": "tool", "toolIndex": 0},
{"type": "text", "content": ",查询完成"},
]
assert message["attachments"] == [{"kind": "file", "url": "message/agent/file/a"}]
def test_agent_chat_display_schema_preserves_ordered_segments():
"""前端回传会话快照时应保留文字和工具的有序片段。"""
payload = schemas.AgentChatDisplaySaveRequest(
messages=[
{
"id": "assistant-1",
"role": "assistant",
"content": "先检查检查完成",
"createdAt": 1,
"status": "done",
"tools": [
{"id": "tool-1", "message": "执行检查", "status": "done"}
],
"segments": [
{"type": "text", "content": "先检查"},
{"type": "tool", "toolIndex": 0},
{"type": "text", "content": "检查完成"},
],
}
]
)
assert payload.messages[0].model_dump()["segments"] == [
{"type": "text", "content": "先检查", "toolIndex": None},
{"type": "tool", "content": "", "toolIndex": 0},
{"type": "text", "content": "检查完成", "toolIndex": None},
]
def test_build_web_agent_input_attachments_marks_kinds():
"""WebAgent 用户输入附件应转换为可展示的附件记录。"""
attachments = _build_web_agent_input_attachments(
images=["data:image/png;base64,abc"],
files=[
{
"ref": "message/agent/file/file-1",
"name": "report.txt",
"mime_type": "text/plain",
"size": 5,
}
],
audio_refs=["message/agent/file/audio-1"],
)
assert [item["kind"] for item in attachments] == ["image", "file", "audio"]
assert attachments[1]["name"] == "report.txt"
def test_build_web_agent_command_items_returns_slash_commands():
"""WebAgent 命令建议应返回可展示的斜杠命令。"""
with patch(
"app.api.endpoints.agent.get_commands",
return_value={
"/sites": {"description": "管理站点", "category": "站点"},
"hidden": {"description": "忽略", "category": "其他"},
"/hidden": {"description": "隐藏", "category": "其他", "show": False},
},
):
commands = _build_web_agent_command_items()
assert commands == [
{
"command": "/sites",
"description": "管理站点",
"category": "站点",
"type": "",
"pid": None,
}
]
def test_build_web_agent_command_items_includes_sites_command():
"""WebAgent 命令建议应包含内建站点管理命令。"""
with patch(
"app.api.endpoints.agent.get_commands",
return_value={
"/sites": {"description": "管理站点", "category": "站点"},
},
):
commands = _build_web_agent_command_items()
assert any(command["command"] == "/sites" for command in commands)
def test_build_web_agent_traditional_callback_payload_wraps_callback():
"""传统按钮回调应包装为可继续提交给 MessageChain 的消息。"""
payload = _build_web_agent_traditional_callback_payload(
"skills:req-1:root",
original_message_id="assistant-1",
original_chat_id="web-session",
)
assert payload["message"] == "CALLBACK:skills:req-1:root"
assert payload["traditional"] is True
assert payload["original_message_id"] == "assistant-1"
assert payload["original_chat_id"] == "web-session"
def test_web_agent_stream_returns_error_for_unknown_command():
"""不存在的 WebAgent 斜杠命令应立即返回错误,不进入等待队列。"""
payload = schemas.AgentWebChatRequest(
text="/missing_command 参数",
session_id="browser-session",
)
request = SimpleNamespace()
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
with patch(
"app.api.endpoints.agent.get_command",
return_value=None,
), patch("app.api.endpoints.agent.MessageChain.handle_message") as handle_message:
response = asyncio.run(web_agent_stream(payload, request, user))
body = "".join(asyncio.run(_collect_streaming_response(response)))
assert "error" in body
assert "命令不存在:/missing_command" in body
handle_message.assert_not_called()
def test_web_agent_stream_does_not_bind_request_scoped_chat_service():
"""流式路由不能把请求级 Agent 会话服务捕获到后台任务。"""
from app.api.dependencies.agent import get_agent_chat_service
from app.api.endpoints import agent as agent_endpoint
route = next(
route
for route in agent_endpoint.router.routes
if getattr(route, "name", None) == "web_agent_stream"
)
assert all(
dependency.call is not get_agent_chat_service
for dependency in route.dependant.dependencies
)
def test_build_web_agent_message_update_event_converts_buttons():
"""WebAgent 编辑消息应转换为可原地更新卡片的事件。"""
event = build_web_agent_message_update_event(
message_id="assistant-1",
title="技能管理",
text="请选择操作",
buttons=[[{"text": "返回", "callback_data": "skills:req-1:root"}]],
)
assert event["type"] == "message_update"
assert event["target_message"]["id"] == "assistant-1"
assert event["target_message"]["choices"][0]["title"] == "技能管理"
assert event["target_message"]["choices"][0]["prompt"] == "请选择操作"
assert event["target_message"]["choices"][0]["buttons"][0]["label"] == "返回"
def test_build_web_agent_display_message_from_events_marks_done():
"""传统消息事件应聚合为完成态助手展示消息。"""
message = _build_web_agent_display_message_from_events([
{"type": "delta", "content": "菜单"},
{
"type": "choice",
"choice": {
"id": "choice-1",
"prompt": "请选择",
"buttons": [{"label": "返回", "callback_data": "back"}],
},
},
])
assert message["content"] == "菜单"
assert message["status"] == "done"
assert message["choices"][0]["prompt"] == "请选择"
def test_has_web_agent_traditional_interaction_detects_pending_skills():
"""WebAgent 应能识别命令后的传统交互上下文。"""
skill_interaction_manager.clear()
try:
skill_interaction_manager.create_or_replace(
user_id="1",
channel=NotificationChannel.WebAgent,
source="web-agent",
username="admin",
)
assert _has_web_agent_traditional_interaction("1") is True
assert _has_web_agent_traditional_interaction("2") is False
finally:
skill_interaction_manager.clear()
def test_web_agent_admin_context_uses_current_user_id():
"""Web Agent 工具权限应按当前登录用户 ID 判断管理员身份。"""
agent = _get_web_agent_type()(
session_id="web-agent:session",
user_id="7",
channel=NotificationChannel.WebAgent.value,
source="web-agent",
username="normal-user",
replay_mode=ReplyMode.CAPTURE_ONLY,
)
lookup_fn = Mock(return_value=SimpleNamespace(is_superuser=True))
with patch(
"app.api.endpoints.agent.get_configured_user_id_lookup",
return_value=lookup_fn,
) as lookup:
assert asyncio.run(agent._is_system_admin_context()) is True
lookup.assert_called_once_with()
lookup_fn.assert_called_once_with(7)
def test_web_agent_reused_for_background_task_disables_streaming():
"""Web Agent 被后台任务复用且渠道已清空时应改用非流式广播。"""
agent = _get_web_agent_type()(
session_id="web-agent:scheduled-session",
user_id="7",
channel=None,
source=None,
username="admin",
replay_mode=ReplyMode.DISPATCH,
)
assert agent.is_background is True
assert agent._should_stream() is False
def test_web_agent_output_callback_receives_only_new_text():
"""WebAgent 外部回调应接收增量,同时内部仍保留完整输出。"""
outputs = []
agent = _get_web_agent_type()(
session_id="web-agent:incremental-output",
user_id="7",
channel=NotificationChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
output_callback=outputs.append,
)
agent._handle_stream_text("你")
agent._handle_stream_text("好")
assert outputs == ["你", "好"]
assert agent._streamed_output == "你好"
def test_web_agent_tool_summary_is_emitted_before_following_text():
"""Web 工具状态应在调用发生时输出,不能拖到正文结束后。"""
outputs = []
agent = _get_web_agent_type()(
session_id="web-agent:tool-order",
user_id="7",
channel=NotificationChannel.WebAgent.value,
source="web-agent",
username="admin",
replay_mode=ReplyMode.CAPTURE_ONLY,
output_callback=outputs.append,
)
agent.stream_handler.record_tool_call("query_download_tasks")
agent._handle_stream_text("查询完成。")
assert outputs == ["(查询了 1 次数据)\n\n", "查询完成。"]
def test_web_agent_channel_supports_streaming_and_attachments():
"""WebAgent 渠道应声明流式、多媒体和文件发送能力。"""
assert ChannelCapabilityManager.supports_capability(
NotificationChannel.WebAgent, ChannelCapability.INLINE_BUTTONS
)
assert ChannelCapabilityManager.supports_capability(
NotificationChannel.WebAgent, ChannelCapability.CALLBACK_QUERIES
)
assert ChannelCapabilityManager.supports_capability(
NotificationChannel.WebAgent, ChannelCapability.MESSAGE_EDITING
)
assert ChannelCapabilityManager.supports_capability(
NotificationChannel.WebAgent, ChannelCapability.IMAGES
)
assert ChannelCapabilityManager.supports_capability(
NotificationChannel.WebAgent, ChannelCapability.AUDIO_OUTPUT
)
assert ChannelCapabilityManager.supports_capability(
NotificationChannel.WebAgent, ChannelCapability.FILE_SENDING
)
def test_build_web_agent_message_events_extracts_image():
"""Agent 工具发送图片消息时应转换为图片附件事件。"""
events = _build_web_agent_message_events(
schemas.Message(
channel=NotificationChannel.WebAgent,
mtype=MessageType.Agent,
title="海报",
text="已找到图片",
image="https://example.com/poster.jpg",
)
)
assert events == [
{"type": "delta", "content": "海报\n\n已找到图片"},
{
"type": "attachment",
"attachment": {
"kind": "image",
"url": "https://example.com/poster.jpg",
"download_url": "https://example.com/poster.jpg",
"name": "海报",
"mime_type": None,
},
},
]
def test_extract_web_agent_message_supports_wrapped_message_event():
"""NoticeMessage 包装 Message 时应仍能解析为 WebAgent 通知。"""
message = schemas.Message(
channel=NotificationChannel.WebAgent,
source="web-agent",
title="会话状态",
userid="1",
)
extracted = extract_web_agent_message_from_event_data(
{"message": message, "current_time": "2026-06-26 09:18:38"}
)
assert extracted == message
def test_dispatch_web_agent_message_event_accepts_wrapped_message_event():
"""WebAgent 等待队列应接收 message 包装格式的 NoticeMessage 事件。"""
notice_queue = Queue()
attach_web_agent_message_queue("1", notice_queue)
message = schemas.Message(
channel=NotificationChannel.WebAgent,
source="web-agent",
title="会话状态",
userid="1",
)
try:
dispatch_web_agent_message_event(
Event(
EventType.NoticeMessage,
{"message": message, "current_time": "2026-06-26 09:18:38"},
)
)
finally:
detach_web_agent_message_queue("1", notice_queue)
assert notice_queue.get_nowait() == message
def test_collect_web_agent_traditional_events_does_not_emit_submit_hint():
"""传统命令未产生通知时不应返回“命令已提交”的兜底提示。"""
user = SimpleNamespace(id=1, name="admin")
with patch(
"app.api.endpoints.agent.MessageChain.handle_message",
), patch(
"app.api.endpoints.agent.WEB_AGENT_TRADITIONAL_IDLE_TIMEOUT_SECONDS",
0.01,
), patch(
"app.api.endpoints.agent.WEB_AGENT_TRADITIONAL_MAX_WAIT_SECONDS",
0.05,
):
events = asyncio.run(
_collect_web_agent_traditional_events(
text="/session_status",
current_user=user,
)
)
assert events == []
def test_build_web_agent_message_events_registers_local_file(tmp_path):
"""Agent 工具发送本地文件时应生成可下载附件事件。"""
file_path = tmp_path / "report.txt"
file_path.write_text("hello", encoding="utf-8")
events = _build_web_agent_message_events(
schemas.Message(
channel=NotificationChannel.WebAgent,
mtype=MessageType.Agent,
file_path=str(file_path),
file_name="report.txt",
)
)
assert len(events) == 1
attachment = events[0]["attachment"]
assert events[0]["type"] == "attachment"
assert attachment["kind"] == "file"
assert attachment["name"] == "report.txt"
assert attachment["mime_type"] == "text/plain"
assert attachment["size"] == 5
assert attachment["url"].startswith("message/agent/file/")
def test_build_web_agent_message_events_registers_voice_attachment(tmp_path):
"""Agent 工具发送语音时应转换为可播放的音频附件事件。"""
voice_path = tmp_path / "reply.wav"
voice_path.write_bytes(b"wav-bytes")
events = _build_web_agent_message_events(
schemas.Message(
channel=NotificationChannel.WebAgent,
mtype=MessageType.Agent,
text="你好",
voice_path=str(voice_path),
)
)
assert len(events) == 2
assert events[0] == {"type": "delta", "content": "你好"}
attachment = events[1]["attachment"]
assert events[1]["type"] == "attachment"
assert attachment["kind"] == "audio"
assert attachment["name"] == "reply.wav"
assert attachment["mime_type"] == "audio/wav"
assert attachment["size"] == len(b"wav-bytes")
assert attachment["url"].startswith("message/agent/file/")
def test_prepare_web_agent_audio_attachment_async_keeps_loop_responsive(tmp_path):
"""异步转码等待期间事件循环仍应可调度其它任务。"""
source_path = tmp_path / "reply.opus"
source_path.write_bytes(b"opus-bytes")
converted_path = tmp_path / "voice" / "reply_web_abcdef12.wav"
started = asyncio.Event()
release = asyncio.Event()
class FakeProcess:
returncode = 0
async def communicate(self):
started.set()
await release.wait()
converted_path.write_bytes(b"wav-bytes")
return b"", b""
async def fake_create_subprocess_exec(*args, **kwargs):
assert args[0] == "/usr/bin/ffmpeg"
return FakeProcess()
async def scenario():
with patch(
"app.api.endpoints.agent.shutil.which",
return_value="/usr/bin/ffmpeg",
), patch(
"app.api.endpoints.agent.uuid.uuid4",
return_value=SimpleNamespace(hex="abcdef1234567890"),
), patch(
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
side_effect=fake_create_subprocess_exec,
), patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(temp_path=tmp_path),
):
conversion_task = asyncio.create_task(
_prepare_web_agent_audio_attachment_path_async(str(source_path))
)
await asyncio.wait_for(started.wait(), timeout=1)
heartbeat = asyncio.create_task(asyncio.sleep(0))
await heartbeat
assert not conversion_task.done()
release.set()
return await conversion_task
output_path = asyncio.run(scenario())
assert output_path == converted_path
assert output_path.read_bytes() == b"wav-bytes"
def test_prepare_web_agent_audio_attachment_async_cancellation_reaps_process(tmp_path):
"""取消 WebAgent 转码时应终止并回收 ffmpeg,不能留下半成品。"""
source_path = tmp_path / "reply.opus"
source_path.write_bytes(b"opus-bytes")
output_path = tmp_path / "voice" / "reply_web_abcdef12.wav"
started = asyncio.Event()
killed = False
class FakeProcess:
returncode = None
_release = asyncio.Event()
def kill(self):
nonlocal killed
killed = True
self.returncode = -9
self._release.set()
async def communicate(self):
started.set()
if self.returncode is None:
await self._release.wait()
return b"", b""
async def fake_create_subprocess_exec(*args, **kwargs):
return FakeProcess()
async def scenario():
with patch(
"app.api.endpoints.agent.shutil.which",
return_value="/usr/bin/ffmpeg",
), patch(
"app.api.endpoints.agent.uuid.uuid4",
return_value=SimpleNamespace(hex="abcdef1234567890"),
), patch(
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
side_effect=fake_create_subprocess_exec,
), patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(temp_path=tmp_path),
):
conversion_task = asyncio.create_task(
_prepare_web_agent_audio_attachment_path_async(str(source_path))
)
await asyncio.wait_for(started.wait(), timeout=5)
conversion_task.cancel()
with pytest.raises(asyncio.CancelledError):
await conversion_task
asyncio.run(scenario())
assert killed is True
assert not output_path.exists()
def test_prepare_web_agent_audio_attachment_async_communicate_error_reaps_process(tmp_path):
"""ffmpeg 通信异常时应终止仍运行的进程并回退原文件。"""
source_path = tmp_path / "reply.opus"
source_path.write_bytes(b"opus-bytes")
started = asyncio.Event()
killed = False
communicate_calls = 0
class FakeProcess:
returncode = None
def kill(self):
nonlocal killed
killed = True
self.returncode = -9
async def communicate(self):
nonlocal communicate_calls
communicate_calls += 1
started.set()
if self.returncode is None:
raise OSError("pipe closed")
return b"", b""
async def fake_create_subprocess_exec(*args, **kwargs):
return FakeProcess()
async def scenario():
with patch(
"app.api.endpoints.agent.shutil.which",
return_value="/usr/bin/ffmpeg",
), patch(
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
side_effect=fake_create_subprocess_exec,
), patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(temp_path=tmp_path),
):
output_path = await _prepare_web_agent_audio_attachment_path_async(
str(source_path)
)
return output_path
output_path = asyncio.run(scenario())
assert output_path == source_path
assert killed is True
assert communicate_calls == 2
def test_prepare_web_agent_audio_attachment_async_cancellation_cleans_completed_output(
tmp_path,
):
"""转码完成后检查产物期间取消,也应清理未登记的 WAV。"""
source_path = tmp_path / "reply.opus"
source_path.write_bytes(b"opus-bytes")
output_path = tmp_path / "voice" / "reply_web_abcdef12.wav"
exists_started = asyncio.Event()
class FakeProcess:
returncode = 0
async def communicate(self):
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(b"wav-bytes")
return b"", b""
async def fake_create_subprocess_exec(*args, **kwargs):
return FakeProcess()
async def fake_run_in_threadpool(func, *args, **kwargs):
if getattr(func, "__name__", "") == "exists":
exists_started.set()
await asyncio.Event().wait()
return await asyncio.to_thread(func, *args, **kwargs)
async def scenario():
with patch(
"app.api.endpoints.agent.shutil.which",
return_value="/usr/bin/ffmpeg",
), patch(
"app.api.endpoints.agent.uuid.uuid4",
return_value=SimpleNamespace(hex="abcdef1234567890"),
), patch(
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
side_effect=fake_create_subprocess_exec,
), patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(temp_path=tmp_path),
), patch(
"app.api.endpoints.agent.run_in_threadpool",
side_effect=fake_run_in_threadpool,
):
conversion_task = asyncio.create_task(
_prepare_web_agent_audio_attachment_path_async(str(source_path))
)
await asyncio.wait_for(exists_started.wait(), timeout=1)
assert output_path.exists()
conversion_task.cancel()
with pytest.raises(asyncio.CancelledError):
await conversion_task
asyncio.run(scenario())
assert not output_path.exists()
def test_prepare_web_agent_audio_attachment_async_timeout_falls_back(tmp_path):
"""转码超时应回退原文件并回收 ffmpeg。"""
source_path = tmp_path / "reply.opus"
source_path.write_bytes(b"opus-bytes")
started = asyncio.Event()
killed = False
class FakeProcess:
returncode = None
_release = asyncio.Event()
def kill(self):
nonlocal killed
killed = True
self.returncode = -9
self._release.set()
async def communicate(self):
started.set()
if self.returncode is None:
await self._release.wait()
return b"", b""
async def fake_create_subprocess_exec(*args, **kwargs):
return FakeProcess()
async def scenario():
with patch(
"app.api.endpoints.agent.shutil.which",
return_value="/usr/bin/ffmpeg",
), patch(
"app.api.endpoints.agent.asyncio.create_subprocess_exec",
side_effect=fake_create_subprocess_exec,
), patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(temp_path=tmp_path),
), patch(
"app.api.endpoints.agent.WEB_AGENT_AUDIO_CONVERSION_TIMEOUT_SECONDS",
0.01,
):
conversion_task = asyncio.create_task(
_prepare_web_agent_audio_attachment_path_async(str(source_path))
)
await asyncio.wait_for(started.wait(), timeout=1)
return await conversion_task
output_path = asyncio.run(scenario())
assert output_path == source_path
assert killed is True
def test_transcribe_web_agent_audio_files_reads_registered_upload(tmp_path):
"""WebAgent 上传录音应从临时附件登记表读取并转写为文本。"""
voice_path = tmp_path / "recording.webm"
voice_path.write_bytes(b"webm-bytes")
_WEB_AGENT_FILE_REGISTRY["audio-test"] = {
"path": voice_path,
"name": "recording.webm",
"mime_type": "audio/webm",
"created_at": time.time(),
}
try:
with patch(
"app.api.endpoints.agent.is_audio_input_available",
return_value=True,
), patch(
"app.api.endpoints.agent.transcribe_audio",
return_value="帮我推荐一部电影",
) as transcribe_audio:
audio_files = _resolve_web_agent_audio_refs(
["message/agent/file/audio-test"]
)
_WEB_AGENT_FILE_REGISTRY.pop("audio-test", None)
transcript = _transcribe_web_agent_audio_files(audio_files)
finally:
_WEB_AGENT_FILE_REGISTRY.pop("audio-test", None)
assert transcript == "帮我推荐一部电影"
transcribe_audio.assert_called_once_with(
content=b"webm-bytes",
filename="recording.webm",
)
def test_web_agent_stream_returns_error_when_voice_transcription_fails():
"""仅发送语音且转写失败时应直接返回错误事件。"""
payload = schemas.AgentWebChatRequest(
text="",
session_id="browser-session",
audio_refs=["message/agent/file/missing"],
)
request = SimpleNamespace()
user = SimpleNamespace(id=1, name="admin")
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch(
"app.api.endpoints.agent._transcribe_web_agent_audio_files",
return_value=None,
) as transcribe_audio:
response = asyncio.run(web_agent_stream(payload, request, user))
body = "".join(asyncio.run(_collect_streaming_response(response)))
transcribe_audio.assert_not_called()
assert "error" in body
assert "语音识别失败" in body
def test_web_agent_stream_does_not_block_event_loop_during_transcription():
"""同步音频 provider 等待时,事件循环仍应让其他任务获得执行机会。"""
payload = schemas.AgentWebChatRequest(
text="",
session_id="browser-session",
audio_refs=["message/agent/file/audio-test"],
)
request = SimpleNamespace(headers={})
user = SimpleNamespace(id=1, name="admin")
transcription_started = ThreadEvent()
transcription_release = ThreadEvent()
def blocking_transcription(_audio_refs):
transcription_started.set()
assert transcription_release.wait(timeout=2)
return None
async def scenario():
stream_task = asyncio.create_task(web_agent_stream(payload, request, user))
assert await asyncio.to_thread(transcription_started.wait, 1)
assert stream_task.done() is False
transcription_release.set()
return await stream_task
try:
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch(
"app.api.endpoints.agent._resolve_web_agent_audio_refs",
return_value=[Mock()],
), patch(
"app.api.endpoints.agent._transcribe_web_agent_audio_files",
side_effect=blocking_transcription,
):
response = asyncio.run(scenario())
finally:
transcription_release.set()
body = "".join(asyncio.run(_collect_streaming_response(response)))
assert "语音识别失败" in body
def test_web_agent_stream_binds_session_to_agent_manager():
"""WebAgent 普通对话应统一进入 AgentManager 并绑定远程命令会话。"""
payload = schemas.AgentWebChatRequest(
text="查看会话",
session_id="browser-session",
)
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
class FakeWebAgent:
"""测试用 WebAgent,模拟 AgentManager 内部的持久实例。"""
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self.processed = []
def set_output_callback(self, output_callback):
"""更新当前 SSE 输出回调。"""
self.output_callback = output_callback
def set_protected_output_callback(self, protected_output_callback):
"""更新当前 SSE 受保护输出回调。"""
self.protected_output_callback = protected_output_callback
def set_message_callback(self, message_callback):
"""更新当前 SSE 通知回调。"""
self.message_callback = message_callback
async def process(self, message, **kwargs):
"""模拟一次 WebAgent 推理输出。"""
self.processed.append((message, kwargs))
self.output_callback("状态正常")
return "状态正常"
async def cleanup(self):
"""模拟 Agent 资源清理。"""
return None
session_id = _build_web_agent_session_id(user, payload.session_id)
MessageChain._user_sessions.clear()
agent_manager.active_agents.pop(session_id, None)
agent_manager._session_queues.pop(session_id, None)
worker = agent_manager._session_workers.pop(session_id, None)
if worker:
worker.cancel()
async def scenario():
response = await web_agent_stream(payload, request, user)
return "".join(await _collect_streaming_response(response))
try:
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch(
"app.api.endpoints.agent._get_web_agent_type",
return_value=FakeWebAgent,
):
body = asyncio.run(scenario())
assert "状态正常" in body
assert MessageChain._user_sessions["1"][0] == session_id
assert isinstance(agent_manager.active_agents[session_id], FakeWebAgent)
finally:
MessageChain._user_sessions.clear()
agent = agent_manager.active_agents.pop(session_id, None)
if agent:
asyncio.run(agent.cleanup())
agent_manager._session_queues.pop(session_id, None)
worker = agent_manager._session_workers.pop(session_id, None)
if worker:
worker.cancel()
def test_web_agent_stream_emits_secret_result_only_as_protected_event():
"""敏感结果只能进入命名 protected SSE,不能进入普通快照。"""
secret_marker = "WEB_SECRET_MARKER **literal** <img src=x>"
payload = schemas.AgentWebChatRequest(
text="确认",
session_id="browser-secret",
echo_user=True,
)
request = SimpleNamespace(
headers={"X-MoviePilot-Agent-Interaction": "1"},
is_disconnected=AsyncMock(return_value=False),
)
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
class FakeProtectedAgent:
"""直接触发受保护输出的 WebAgent 测试替身。"""
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self._pending_secret_confirmation = SimpleNamespace(
channel=NotificationChannel.WebAgent.value,
source="web-agent",
original_chat_id="",
)
def has_pending_secret_confirmation(self):
"""模拟当前会话存在有效的敏感读取确认。"""
return self._pending_secret_confirmation is not None
def set_output_callback(self, output_callback):
self.output_callback = output_callback
def set_message_callback(self, message_callback):
self.message_callback = message_callback
def set_protected_output_callback(self, protected_output_callback):
self.protected_output_callback = protected_output_callback
async def process(self, _message, **_kwargs):
self.protected_output_callback(secret_marker)
return "敏感设置确认已处理。"
async def cleanup(self):
return None
session_id = _build_web_agent_session_id(user, payload.session_id)
existing_messages = [
{"role": "user", "content": "此前的问题", "status": "done"},
{"role": "assistant", "content": "此前的回答", "status": "done"},
]
existing_chat = AgentChatOper().save_display_messages(
session_id=session_id,
user_id="1",
username="admin",
channel=NotificationChannel.WebAgent.value,
source="web-agent",
messages=existing_messages,
client_session_id=payload.session_id,
)
agent_manager.active_agents[session_id] = FakeProtectedAgent(
session_id=session_id,
user_id="1",
channel=NotificationChannel.WebAgent.value,
source="web-agent",
username="admin",
)
agent_manager._session_queues.pop(session_id, None)
worker = agent_manager._session_workers.pop(session_id, None)
if worker:
worker.cancel()
async def scenario():
response = await web_agent_stream(payload, request, user)
return "".join(await _collect_streaming_response(response))
try:
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch(
"app.api.endpoints.agent._get_web_agent_type",
return_value=FakeProtectedAgent,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
) as save_snapshot:
body = asyncio.run(scenario())
assert "event: interaction-protected\n" in body
assert secret_marker in body
save_snapshot.assert_not_called()
preserved_chat = AgentChatOper().get(session_id=session_id, user_id="1")
assert preserved_chat.display_messages == existing_messages
assert preserved_chat.message_count == 2
assert preserved_chat.preview == "此前的回答"
finally:
agent = agent_manager.active_agents.pop(session_id, None)
if agent:
asyncio.run(agent.cleanup())
agent_manager._session_queues.pop(session_id, None)
worker = agent_manager._session_workers.pop(session_id, None)
if worker:
worker.cancel()
AgentChatOper().delete_by_id(existing_chat.id)
def test_web_agent_cancel_keeps_existing_display_history():
"""取消敏感读取不得覆盖当前会话已有的普通展示历史。"""
payload = schemas.AgentWebChatRequest(
text="取消",
session_id="browser-secret-cancel",
echo_user=True,
)
request = SimpleNamespace(
headers={"X-MoviePilot-Agent-Interaction": "1"},
is_disconnected=AsyncMock(return_value=False),
)
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
session_id = _build_web_agent_session_id(user, payload.session_id)
existing_messages = [
{"role": "user", "content": "保留的问题", "status": "done"},
{"role": "assistant", "content": "保留的回答", "status": "done"},
]
existing_chat = AgentChatOper().save_display_messages(
session_id=session_id,
user_id="1",
username="admin",
channel=NotificationChannel.WebAgent.value,
source="web-agent",
messages=existing_messages,
client_session_id=payload.session_id,
)
async def scenario():
response = await web_agent_stream(payload, request, user)
return "".join(await _collect_streaming_response(response))
try:
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch.object(
agent_manager,
"matches_secret_confirmation",
return_value=True,
), patch.object(
agent_manager,
"process_message",
new=AsyncMock(return_value="已取消敏感设置读取。"),
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
) as save_snapshot:
body = asyncio.run(scenario())
assert '"type": "done"' in body
save_snapshot.assert_not_called()
preserved_chat = AgentChatOper().get(session_id=session_id, user_id="1")
assert preserved_chat.display_messages == existing_messages
assert preserved_chat.message_count == 2
assert preserved_chat.preview == "保留的回答"
finally:
AgentChatOper().delete_by_id(existing_chat.id)
def test_web_agent_stream_rejects_confirmation_without_protected_capability():
"""旧客户端未声明 protected 能力时不得把确认交给 Agent。"""
payload = schemas.AgentWebChatRequest(
text="确认",
session_id="browser-secret-legacy",
echo_user=False,
)
request = SimpleNamespace(
headers={},
is_disconnected=AsyncMock(return_value=False),
)
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
async def scenario():
response = await web_agent_stream(payload, request, user)
return "".join(await _collect_streaming_response(response))
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch.object(
agent_manager,
"matches_secret_confirmation",
return_value=True,
), patch.object(agent_manager, "process_message", new=AsyncMock()) as process:
body = asyncio.run(scenario())
assert "不支持安全交付" in body
process.assert_not_awaited()
def test_web_agent_stream_keeps_confirmation_without_pending_on_normal_path():
"""无待确认操作时,纯文本确认仍是普通 Agent 消息。"""
payload = schemas.AgentWebChatRequest(
text="确认",
session_id="browser-ordinary-confirmation",
echo_user=True,
)
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
async def scenario():
response = await web_agent_stream(payload, request, user)
body = "".join(await _collect_streaming_response(response))
return response, body
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch.object(
agent_manager,
"process_message",
new=AsyncMock(return_value="普通回复"),
) as process:
response, body = asyncio.run(scenario())
assert "不支持安全交付" not in body
assert response.headers.get("X-MoviePilot-Agent-Control") is None
process.assert_awaited_once()
def test_web_agent_stream_drops_secret_result_after_disconnect():
"""确认请求断线后不改造通用队列,并拒绝向关闭的连接投递密钥。"""
payload = schemas.AgentWebChatRequest(
text="确认",
session_id="browser-secret-disconnect",
echo_user=True,
)
agent_started = asyncio.Event()
release_agent = asyncio.Event()
agent_completed = asyncio.Event()
async def disconnect_after_agent_starts():
"""等待确认进入处理流程后再模拟浏览器断线。"""
await agent_started.wait()
return True
request = SimpleNamespace(
headers={"X-MoviePilot-Agent-Interaction": "1"},
is_disconnected=AsyncMock(side_effect=disconnect_after_agent_starts),
)
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
session_id = _build_web_agent_session_id(user, payload.session_id)
existing_messages = [
{"role": "user", "content": "断线前的问题", "status": "done"},
{"role": "assistant", "content": "断线前的回答", "status": "done"},
]
existing_chat = AgentChatOper().save_display_messages(
session_id=session_id,
user_id="1",
username="admin",
channel=NotificationChannel.WebAgent.value,
source="web-agent",
messages=existing_messages,
client_session_id=payload.session_id,
)
delivery_results = []
async def finish_after_disconnect(**kwargs):
"""断线后继续完成只读任务,并尝试向已关闭发布器投递。"""
agent_started.set()
await release_agent.wait()
delivery_results.append(
kwargs["protected_output_callback"]("DISCONNECTED_SECRET_MARKER")
)
agent_completed.set()
async def scenario():
response = await web_agent_stream(payload, request, user)
body = "".join(
await _collect_streaming_response(
response,
wait_for_background=False,
)
)
release_agent.set()
await asyncio.wait_for(agent_completed.wait(), timeout=1)
await wait_web_agent_background_tasks()
return body
try:
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch.object(
agent_manager,
"matches_secret_confirmation",
return_value=True,
), patch.object(
agent_manager,
"process_message",
new=AsyncMock(side_effect=finish_after_disconnect),
) as process, patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
) as save_snapshot:
body = asyncio.run(scenario())
assert '"type": "start"' in body
assert delivery_results == [False]
assert "cancel_on_waiter_cancel" not in process.await_args.kwargs
save_snapshot.assert_not_called()
preserved_chat = AgentChatOper().get(session_id=session_id, user_id="1")
assert preserved_chat.display_messages == existing_messages
assert preserved_chat.message_count == 2
assert preserved_chat.preview == "断线前的回答"
finally:
AgentChatOper().delete_by_id(existing_chat.id)
def test_web_agent_stream_emits_heartbeat_during_idle_tool_wait():
"""长时间没有 Agent 事件时应发送 SSE heartbeat 保持连接。"""
payload = schemas.AgentWebChatRequest(text="分析系统状态", session_id="browser-heartbeat")
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
async def slow_process_message(**kwargs):
"""模拟工具执行期间暂时没有可见输出。"""
await asyncio.sleep(0.035)
kwargs["output_callback"]("状态正常")
async def scenario():
response = await web_agent_stream(payload, request, user)
return "".join(await _collect_streaming_response(response))
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(ai_agent_enable=True),
), patch(
"app.api.endpoints.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS",
0.01,
), 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._build_web_agent_session_id_async",
return_value="web-agent:heartbeat",
), patch.object(
MessageChain,
"bind_user_session",
), patch.object(
agent_manager,
"process_message",
side_effect=slow_process_message,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
):
body = asyncio.run(scenario())
assert ": heartbeat\n\n" in body
assert '"type": "delta"' in body
assert '"type": "done"' in body
def test_web_agent_stop_finishes_stream_without_error():
"""停止运行中的 Web Agent 后应正常结束 SSE,不能继续等待或报执行错误。"""
payload = schemas.AgentWebChatRequest(
text="执行长任务",
session_id="browser-stop",
)
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
session_id = "web-agent:stop"
class BlockingWebAgent:
"""阻塞到会话 worker 被停止的 Web Agent 替身。"""
started = None
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
async def process(self, _message, **_kwargs):
"""等待外层 worker 取消。"""
self.started.set()
await asyncio.Event().wait()
async def cleanup(self):
"""模拟 Agent 资源清理。"""
return None
async def scenario():
BlockingWebAgent.started = asyncio.Event()
response = await web_agent_stream(payload, request, user)
iterator = response.body_iterator.__aiter__()
received = [await asyncio.wait_for(anext(iterator), timeout=1)]
await asyncio.wait_for(BlockingWebAgent.started.wait(), timeout=1)
assert await asyncio.wait_for(
agent_manager.stop_current_task(session_id), timeout=1
) is True
while '"type": "done"' not in "".join(received):
received.append(await asyncio.wait_for(anext(iterator), timeout=1))
await iterator.aclose()
await wait_web_agent_background_tasks()
return "".join(received)
try:
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(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._build_web_agent_session_id_async",
return_value=session_id,
), patch.object(
MessageChain,
"bind_user_session",
), patch(
"app.api.endpoints.agent._get_web_agent_type",
return_value=BlockingWebAgent,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
):
body = asyncio.run(scenario())
finally:
agent_manager._session_queues.pop(session_id, None)
agent_manager._session_workers.pop(session_id, None)
agent_manager.active_agents.pop(session_id, None)
assert '"type": "done"' in body
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.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(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",
new_callable=AsyncMock,
):
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")
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
snapshot_started = ThreadEvent()
snapshot_release = ThreadEvent()
snapshot_finished = ThreadEvent()
async def slow_collect(**_kwargs):
"""模拟传统消息链路等待外部结果。"""
await asyncio.sleep(0.035)
return [{"type": "delta", "content": "状态正常"}]
async def slow_snapshot(**_kwargs):
"""阻塞快照写入,便于断言 done 不等待落库。"""
await asyncio.to_thread(snapshot_started.set)
await asyncio.to_thread(snapshot_release.wait, 2)
await asyncio.to_thread(snapshot_finished.set)
async def scenario():
response = await web_agent_stream(payload, request, user)
assert response.headers["cache-control"] == "no-cache, no-transform"
iterator = response.body_iterator.__aiter__()
received = []
while True:
chunk = await asyncio.wait_for(anext(iterator), timeout=1)
text = chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
received.append(text)
if '"type": "done"' in text:
break
for _ in range(100):
if snapshot_started.is_set():
break
await asyncio.sleep(0.001)
assert snapshot_started.is_set()
assert not snapshot_finished.is_set()
await iterator.aclose()
assert not snapshot_finished.is_set()
snapshot_release.set()
await asyncio.to_thread(snapshot_finished.wait, 1)
await wait_web_agent_background_tasks()
return "".join(received)
try:
with patch(
"app.api.endpoints.agent.WEB_AGENT_STREAM_HEARTBEAT_SECONDS",
0.01,
), patch(
"app.api.endpoints.agent._is_web_agent_traditional_message",
return_value=True,
), patch(
"app.api.endpoints.agent._ensure_web_agent_command_allowed",
return_value=None,
), patch(
"app.api.endpoints.agent._get_web_agent_unknown_command_message",
return_value=None,
), patch(
"app.api.endpoints.agent._build_web_agent_session_id_async",
return_value="web-agent:traditional-heartbeat",
), patch(
"app.api.endpoints.agent._collect_web_agent_traditional_events",
side_effect=slow_collect,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
side_effect=slow_snapshot,
):
body = asyncio.run(scenario())
assert ": heartbeat\n\n" in body
assert '"type": "delta"' in body
assert '"type": "done"' in body
finally:
snapshot_release.set()
assert snapshot_finished.wait(timeout=1)
def test_web_agent_traditional_stream_drains_collection_on_cancellation():
"""传统 SSE 被取消时必须等待请求级 collection 子任务完成清理。"""
payload = schemas.AgentWebChatRequest(text="/状态", session_id="traditional-cancel")
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
async def scenario():
"""取消正在等待的 SSE 读取,并观察 collection 的清理时序。"""
started = asyncio.Event()
cancelling = asyncio.Event()
release_cleanup = asyncio.Event()
cleanup_finished = asyncio.Event()
async def blocked_collect(**_kwargs):
"""阻塞传统消息收集,并在取消后等待测试释放清理。"""
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelling.set()
await release_cleanup.wait()
cleanup_finished.set()
raise
with patch(
"app.api.endpoints.agent._is_web_agent_traditional_message",
return_value=True,
), patch(
"app.api.endpoints.agent._ensure_web_agent_command_allowed",
return_value=None,
), patch(
"app.api.endpoints.agent._get_web_agent_unknown_command_message",
return_value=None,
), patch(
"app.api.endpoints.agent._build_web_agent_session_id_async",
return_value="web-agent:traditional-cancel",
), patch(
"app.api.endpoints.agent._collect_web_agent_traditional_events",
side_effect=blocked_collect,
):
response = await web_agent_stream(payload, request, user)
iterator = response.body_iterator.__aiter__()
await asyncio.wait_for(anext(iterator), timeout=1)
pending_chunk = asyncio.create_task(anext(iterator))
await asyncio.wait_for(started.wait(), timeout=1)
pending_chunk.cancel()
await asyncio.wait_for(cancelling.wait(), timeout=1)
assert pending_chunk.done() is False
assert cleanup_finished.is_set() is False
release_cleanup.set()
result = await asyncio.gather(pending_chunk, return_exceptions=True)
assert isinstance(result[0], StopAsyncIteration)
assert cleanup_finished.is_set() is True
asyncio.run(scenario())
def test_web_agent_stream_sends_done_before_snapshot_persistence_finishes():
"""展示快照落库缓慢时,前端终态不应被数据库操作阻塞。"""
payload = schemas.AgentWebChatRequest(text="检查系统", session_id="browser-snapshot")
request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
user = SimpleNamespace(id=1, name="admin", is_superuser=True)
snapshot_started = ThreadEvent()
snapshot_release = ThreadEvent()
snapshot_finished = ThreadEvent()
async def immediate_process_message(**kwargs):
"""立即生成一段文本,随后进入终态。"""
kwargs["output_callback"]("检查完成")
async def slow_snapshot(**_kwargs):
"""阻塞快照写入,便于验证 done 的发送时机。"""
await asyncio.to_thread(snapshot_started.set)
await asyncio.to_thread(snapshot_release.wait, 2)
await asyncio.to_thread(snapshot_finished.set)
async def scenario():
response = await web_agent_stream(payload, request, user)
iterator = response.body_iterator.__aiter__()
received = []
while True:
chunk = await asyncio.wait_for(anext(iterator), timeout=1)
text = chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
received.append(text)
if '"type": "done"' in text:
break
for _ in range(100):
if snapshot_started.is_set():
break
await asyncio.sleep(0.001)
assert snapshot_started.is_set()
assert not snapshot_finished.is_set()
await iterator.aclose()
assert not snapshot_finished.is_set()
snapshot_release.set()
await asyncio.to_thread(snapshot_finished.wait, 1)
await wait_web_agent_background_tasks()
return "".join(received)
try:
with patch(
"app.api.endpoints.agent.get_api_runtime_config_snapshot",
return_value=SimpleNamespace(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._build_web_agent_session_id_async",
return_value="web-agent:snapshot",
), patch.object(
MessageChain,
"bind_user_session",
), patch.object(
agent_manager,
"process_message",
side_effect=immediate_process_message,
), patch(
"app.api.endpoints.agent._save_web_agent_display_snapshot",
new_callable=AsyncMock,
side_effect=slow_snapshot,
):
body = asyncio.run(scenario())
assert '"type": "done"' in body
finally:
snapshot_release.set()
assert snapshot_finished.wait(timeout=1)
async def _collect_streaming_response(
response,
*,
wait_for_background: bool = True,
):
"""读取 StreamingResponse,并按用例语义等待生产 owner 完成收尾。"""
chunks = []
try:
async for chunk in response.body_iterator:
chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk)
finally:
if wait_for_background:
await wait_web_agent_background_tasks()
return chunks
def test_build_web_agent_message_events_extracts_choice_card():
"""Agent 按钮通知应转换为 Web 选择卡片事件而非普通文本。"""
events = _build_web_agent_message_events(
schemas.Message(
channel=NotificationChannel.WebAgent,
mtype=MessageType.Agent,
title="需要你的选择",
text="请选择要执行的操作",
buttons=[
[
{
"text": "继续下载",
"callback_data": "agent_interaction:choice:req-1:1",
"description": "继续当前下载任务",
}
],
[
{
"text": "查看详情",
"callback_data": "agent_interaction:choice:req-1:2",
}
],
],
)
)
assert events == [
{
"type": "choice",
"choice": {
"id": "req-1",
"title": "需要你的选择",
"prompt": "请选择要执行的操作",
"buttons": [
{
"label": "继续下载",
"callback_data": "agent_interaction:choice:req-1:1",
"description": "继续当前下载任务",
},
{
"label": "查看详情",
"callback_data": "agent_interaction:choice:req-1:2",
},
],
"button_rows": [
[
{
"label": "继续下载",
"callback_data": "agent_interaction:choice:req-1:1",
"description": "继续当前下载任务",
}
],
[
{
"label": "查看详情",
"callback_data": "agent_interaction:choice:req-1:2",
}
],
],
},
}
]
def test_resolve_web_agent_choice_payload_returns_next_message():
"""Web 按钮回调应解析为下一条用户消息并返回卡片反馈。"""
agent_interaction_manager.clear()
request = agent_interaction_manager.create_request(
session_id="web-agent:session",
user_id="1",
channel=NotificationChannel.WebAgent.value,
source="web-agent",
username="admin",
title="需要你的选择",
prompt="请选择",
options=[
AgentInteractionOption(label="电影", value="我选择电影"),
AgentInteractionOption(label="电视剧", value="我选择电视剧", description="选择电视剧并继续清理日志"),
],
)
try:
result = _resolve_web_agent_choice_payload(
callback_data=f"agent_interaction:choice:{request.request_id}:2",
user_id="1",
)
finally:
agent_interaction_manager.clear()
assert result["message"] == "我选择电视剧"
assert result["display_message"] == "选择电视剧并继续清理日志"
assert result["session_id"] == "web-agent:session"
assert result["feedback"]["prompt"] == "请选择"
assert result["feedback"]["selected_label"] == "电视剧"
assert result["feedback"]["selected_value"] == "我选择电视剧"
assert result["feedback"]["selected_description"] == "选择电视剧并继续清理日志"
assert result["choice_selection"]["prompt"] == "请选择"
assert result["choice_selection"]["selected_description"] == "选择电视剧并继续清理日志"
assert result["choice_selection"]["button_rows"][1][0]["description"] == "选择电视剧并继续清理日志"