Files
MoviePilot/tests/test_agent_steering.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

350 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
运行中 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 == "不能丢失"