mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
1921 lines
69 KiB
Python
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"] == "选择电视剧并继续清理日志"
|