mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
350 lines
14 KiB
Python
350 lines
14 KiB
Python
"""
|
||
运行中 Agent 消息排队、真实 HumanMessage 注入及身份隔离测试。
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
from unittest.mock import AsyncMock, Mock
|
||
|
||
import pytest
|
||
from langchain.agents import create_agent
|
||
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||
from langchain_core.messages import AIMessage, HumanMessage
|
||
from langgraph.checkpoint.memory import InMemorySaver
|
||
|
||
from app.agent.middleware.steering import SteeringMiddleware
|
||
from app.agent.orchestrator import MoviePilotAgent
|
||
from app.agent.session import AgentSessionOwner, _MessageTask
|
||
from app.agent.steering import STEERING_QUEUE_MAX_SIZE, SteeringInbox, bind_steering_inbox, reset_steering_inbox
|
||
from app.schemas.types import NotificationChannel, ReplyMode
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_steering_message_is_injected_as_real_human_message() -> None:
|
||
"""
|
||
下一次模型调用应在图状态中看到真实 HumanMessage,而不是提示词拼接。
|
||
"""
|
||
inbox = SteeringInbox("session", "user")
|
||
await inbox.begin_run()
|
||
queued = await inbox.enqueue(user_id="user", text="补充要求")
|
||
assert queued is not None
|
||
|
||
graph = create_agent(
|
||
model=GenericFakeChatModel(messages=iter([AIMessage(content="完成")])),
|
||
tools=[],
|
||
middleware=[SteeringMiddleware()],
|
||
checkpointer=InMemorySaver(),
|
||
)
|
||
token = bind_steering_inbox(inbox)
|
||
try:
|
||
result = await graph.ainvoke(
|
||
{"messages": [HumanMessage(content="开始任务")]},
|
||
config={"configurable": {"thread_id": "session"}},
|
||
)
|
||
finally:
|
||
reset_steering_inbox(token)
|
||
|
||
messages = result["messages"]
|
||
assert [message.type for message in messages] == ["human", "human", "ai"]
|
||
assert "补充要求" in messages[1].content[0]["text"]
|
||
assert messages[1].additional_kwargs["moviepilot_steering_message_id"] == queued.message_id
|
||
steering_payload = json.loads(messages[1].content[0]["text"])
|
||
assert "保留此前的范围" in steering_payload["continuation_context"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_steering_does_not_break_unresolved_tool_call_pairing() -> None:
|
||
"""
|
||
未交付工具调用所在的状态边界不应插入用户消息。
|
||
"""
|
||
inbox = SteeringInbox("session", "user")
|
||
await inbox.begin_run()
|
||
assert await inbox.enqueue(user_id="user", text="稍等") is not None
|
||
middleware = SteeringMiddleware()
|
||
token = bind_steering_inbox(inbox)
|
||
seen_messages = []
|
||
|
||
async def handler(request):
|
||
"""
|
||
记录未注入的模型请求,模拟下游模型处理。
|
||
"""
|
||
seen_messages.extend(request.messages)
|
||
return ModelResponse(result=[AIMessage(content="继续执行工具")])
|
||
|
||
try:
|
||
pending_tool_message = AIMessage(
|
||
content="",
|
||
tool_calls=[{"name": "tool", "args": {}, "id": "call"}],
|
||
)
|
||
await middleware.awrap_model_call(
|
||
request=ModelRequest(
|
||
model=GenericFakeChatModel(messages=iter([AIMessage(content="unused")])),
|
||
tools=[],
|
||
system_message=None,
|
||
response_format=None,
|
||
messages=[pending_tool_message],
|
||
tool_choice=None,
|
||
state={"messages": [pending_tool_message]},
|
||
runtime=None,
|
||
),
|
||
handler=handler,
|
||
)
|
||
finally:
|
||
reset_steering_inbox(token)
|
||
assert seen_messages[-1].tool_calls
|
||
assert inbox.pending_count == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_owner_routes_matching_user_message_to_active_steering_inbox() -> None:
|
||
"""
|
||
活动会话只接受同一用户的补充消息,其他用户回退到普通入口。
|
||
"""
|
||
started = asyncio.Event()
|
||
release = asyncio.Event()
|
||
processed_messages: list[str] = []
|
||
|
||
class BlockingAgent:
|
||
"""
|
||
模拟持久会话 Agent,暴露运行边界供 owner 测试。
|
||
"""
|
||
|
||
def __init__(self, **kwargs):
|
||
"""
|
||
保存宿主注入字段。
|
||
"""
|
||
self.__dict__.update(kwargs)
|
||
self.user_id = str(kwargs["user_id"])
|
||
|
||
def set_output_callback(self, callback):
|
||
"""
|
||
兼容会话 owner 的输出回调更新。
|
||
"""
|
||
self.output_callback = callback
|
||
|
||
def set_protected_output_callback(self, callback):
|
||
"""
|
||
兼容会话 owner 的敏感输出回调更新。
|
||
"""
|
||
self.protected_output_callback = callback
|
||
|
||
def get_session_status(self):
|
||
"""
|
||
返回 owner 状态合并所需的最小快照。
|
||
"""
|
||
return {"model": "fake", "is_processing": True}
|
||
|
||
async def process(self, message, **kwargs):
|
||
"""
|
||
阻塞到测试释放,模拟长时间模型运行。
|
||
"""
|
||
processed_messages.append(message)
|
||
started.set()
|
||
await release.wait()
|
||
return message
|
||
|
||
async def cleanup(self):
|
||
"""
|
||
报告测试 Agent 已收敛。
|
||
"""
|
||
return True
|
||
|
||
owner = AgentSessionOwner()
|
||
owner._accepting_tasks = True
|
||
running = asyncio.create_task(
|
||
owner.process_message(
|
||
session_id="session",
|
||
user_id="user",
|
||
message="开始",
|
||
agent_factory=BlockingAgent,
|
||
wait_for_completion=True,
|
||
)
|
||
)
|
||
await asyncio.wait_for(started.wait(), 1)
|
||
accepted = await owner.submit_steering_message(
|
||
session_id="session",
|
||
user_id="user",
|
||
message="继续检查",
|
||
)
|
||
rejected = await owner.submit_steering_message(
|
||
session_id="session",
|
||
user_id="other",
|
||
message="越权",
|
||
)
|
||
assert accepted is not None
|
||
assert rejected is None
|
||
assert owner.get_session_status("session")["steering_pending"] == 1
|
||
|
||
release.set()
|
||
assert await asyncio.wait_for(running, 1) == "继续检查"
|
||
assert processed_messages == ["开始", "继续检查"]
|
||
worker = owner._session_workers.get("session")
|
||
if worker:
|
||
worker.cancel()
|
||
await asyncio.gather(worker, return_exceptions=True)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_steering_finish_boundary_returns_pending_messages_once() -> None:
|
||
"""
|
||
运行收尾与入队共享锁时,未注入消息只能被交给下一轮一次。
|
||
"""
|
||
inbox = SteeringInbox("session", "user")
|
||
await inbox.begin_run()
|
||
message = await inbox.enqueue(user_id="user", text="收尾前到达")
|
||
assert message is not None
|
||
pending = await inbox.finish_run()
|
||
assert pending == (message,)
|
||
assert await inbox.finish_run() == ()
|
||
assert await inbox.enqueue(user_id="user", text="已关闭运行") is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("channel", [NotificationChannel.Telegram, NotificationChannel.Feishu, NotificationChannel.Wechat])
|
||
async def test_channel_followup_enters_running_graph_without_starting_another_task(monkeypatch, channel) -> None:
|
||
"""
|
||
真实会话入口应把连续补充消息注入当前图,并仅在消费时切分渠道回复。
|
||
"""
|
||
started = asyncio.Event()
|
||
release = asyncio.Event()
|
||
results = []
|
||
processed = []
|
||
monkeypatch.setattr("app.agent.session._async_start_processing_status", AsyncMock(return_value=None))
|
||
monkeypatch.setattr("app.agent.session._async_finish_processing_status", AsyncMock())
|
||
|
||
class ChannelAgent:
|
||
"""
|
||
使用真实 steering 中间件,隔离模型与消息渠道的测试 Agent。
|
||
"""
|
||
|
||
def __init__(self, **kwargs):
|
||
"""
|
||
接收会话上下文并记录消息切分调用。
|
||
"""
|
||
self.__dict__.update(kwargs)
|
||
self.stream_handler = Mock()
|
||
self._tool_context = {"user_reply_sent": True}
|
||
|
||
def start_steering_reply(self, original_message_id=None):
|
||
"""
|
||
复用生产回复切分,验证补充输入解除前段主动回复的抑制状态。
|
||
"""
|
||
MoviePilotAgent.start_steering_reply(self, original_message_id)
|
||
|
||
async def process(self, message, **_kwargs):
|
||
"""
|
||
暂停当前任务,让补充输入在下一次真实模型调用前进入 inbox。
|
||
"""
|
||
processed.append(message)
|
||
started.set()
|
||
await release.wait()
|
||
graph = create_agent(
|
||
model=GenericFakeChatModel(messages=iter([AIMessage(content="已按补充要求完成")])),
|
||
tools=[],
|
||
middleware=[SteeringMiddleware()],
|
||
checkpointer=InMemorySaver(),
|
||
)
|
||
token = bind_steering_inbox(self.steering_inbox)
|
||
try:
|
||
result = await graph.ainvoke(
|
||
{"messages": [HumanMessage(content=message), AIMessage(content="正在检查")]},
|
||
config={"configurable": {"thread_id": "channel-session"}},
|
||
)
|
||
results.extend(result["messages"])
|
||
finally:
|
||
reset_steering_inbox(token)
|
||
return "完成"
|
||
|
||
owner = AgentSessionOwner()
|
||
owner._accepting_tasks = True
|
||
context = {
|
||
"session_id": "channel-session", "user_id": "user", "channel": channel.value,
|
||
"source": "configured-channel", "original_chat_id": "chat", "is_channel_admin": True,
|
||
}
|
||
try:
|
||
await owner.process_message(**context, message="检查服务", agent_factory=ChannelAgent)
|
||
await asyncio.wait_for(started.wait(), 1)
|
||
agent = owner.active_agents["channel-session"]
|
||
await owner.process_message(
|
||
**context, message="补充检查目录", original_message_id="user-2",
|
||
images=["data:image/png;base64,test"], files=[{"ref": "log.txt"}],
|
||
)
|
||
await owner.process_message(**context, message="仅报告异常", original_message_id="user-3")
|
||
assert owner._session_queues["channel-session"].empty()
|
||
assert agent.steering_inbox.pending_count == 2
|
||
agent.stream_handler.start_new_message.assert_not_called()
|
||
release.set()
|
||
await asyncio.wait_for(owner._session_queues["channel-session"].join(), 2)
|
||
|
||
assert processed == ["检查服务"]
|
||
assert [message.type for message in results] == ["human", "ai", "human", "human", "ai"]
|
||
payload = json.loads(results[2].content[0]["text"])
|
||
assert payload["message"] == "补充检查目录"
|
||
assert payload["files"] == [{"ref": "log.txt"}]
|
||
assert results[2].content[1]["image_url"]["url"] == "data:image/png;base64,test"
|
||
assert json.loads(results[3].content[0]["text"])["message"] == "仅报告异常"
|
||
assert [call.args for call in agent.stream_handler.start_new_message.call_args_list] == [("user-2",), ("user-3",)]
|
||
assert agent.original_message_id == "user-3"
|
||
assert agent._tool_context["user_reply_sent"] is False
|
||
finally:
|
||
release.set()
|
||
worker = owner._session_workers.get("channel-session")
|
||
if worker:
|
||
worker.cancel()
|
||
await asyncio.gather(worker, return_exceptions=True)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("changed", [
|
||
{"user_id": "another-user"}, {"source": "another-bot"}, {"original_chat_id": "another-chat"},
|
||
{"is_channel_admin": False}, {"channel": NotificationChannel.Feishu.value},
|
||
{"channel": NotificationChannel.WebAgent.value}, {"reply_mode": ReplyMode.CAPTURE_ONLY},
|
||
{"allow_message_tools": False}, {"scheduled_run_id": "scheduled"},
|
||
])
|
||
async def test_channel_steering_preserves_identity_and_independent_task_boundaries(changed) -> None:
|
||
"""
|
||
其它用户、聊天、渠道、权限或独立调用不得进入已有渠道任务的上下文。
|
||
"""
|
||
context = {
|
||
"session_id": "session", "user_id": "user", "channel": NotificationChannel.Telegram.value,
|
||
"source": "bot", "original_chat_id": "chat", "is_channel_admin": True,
|
||
}
|
||
owner = AgentSessionOwner()
|
||
owner._accepting_tasks = True
|
||
inbox = SteeringInbox("session", "user")
|
||
await inbox.begin_run()
|
||
owner._session_active_tasks["session"] = _MessageTask(**context, message="开始")
|
||
owner._session_steering_inboxes["session"] = inbox
|
||
owner._session_queues["session"] = asyncio.Queue()
|
||
owner._session_workers["session"] = Mock(done=lambda: False)
|
||
await owner.process_message(**{**context, **changed}, message="独立请求")
|
||
assert inbox.pending_count == 0
|
||
assert owner._session_queues["session"].get_nowait().message == "独立请求"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("running", [True, False])
|
||
async def test_channel_steering_falls_back_to_queue_when_full_or_finished(running) -> None:
|
||
"""
|
||
inbox 满或运行已收尾时,补充消息仍进入普通队列且只保留一次。
|
||
"""
|
||
context = {
|
||
"session_id": "session", "user_id": "user", "channel": NotificationChannel.Telegram.value,
|
||
"source": "bot",
|
||
}
|
||
owner = AgentSessionOwner()
|
||
owner._accepting_tasks = True
|
||
inbox = SteeringInbox("session", "user")
|
||
if running:
|
||
await inbox.begin_run()
|
||
for index in range(STEERING_QUEUE_MAX_SIZE):
|
||
await inbox.enqueue(user_id="user", text=str(index))
|
||
owner._session_active_tasks["session"] = _MessageTask(**context, message="开始")
|
||
owner._session_steering_inboxes["session"] = inbox
|
||
owner._session_queues["session"] = asyncio.Queue()
|
||
owner._session_workers["session"] = Mock(done=lambda: False)
|
||
await owner.process_message(**context, message="不能丢失")
|
||
assert inbox.pending_count == (STEERING_QUEUE_MAX_SIZE if running else 0)
|
||
assert owner._session_queues["session"].get_nowait().message == "不能丢失"
|