Files
MoviePilot/tests/test_agent_terminal_scope.py
2026-09-11 03:12:16 +08:00

323 lines
14 KiB
Python
Raw 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 与会话 worker 的终端归属装配;模型、外部服务均停在调用边界。"""
import asyncio
import os
import shlex
import subprocess
import sys
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock, Mock
import pytest
import pytest_asyncio
from app.agent import orchestrator, session
from app.agent.manager import AgentManager
from app.agent.orchestrator import MoviePilotAgent
from app.agent.session import AgentManagerUnavailableError, _MessageTask
from app.agent.terminal import manager as terminal
from app.agent.terminal.manager import _TerminalSessionManager
from app.agent.terminal.ownership import (
TerminalAccessError,
TerminalScope,
bind_terminal_scope,
current_terminal_scope,
require_terminal_scope,
)
@pytest.fixture
def closed_scopes(monkeypatch) -> list[TerminalScope]:
"""只替换进程收口边界,保留真实的宿主 scope 和 worker 生命周期。"""
closed = []
async def close(scope: TerminalScope) -> bool:
"""记录真实对象的封口,避免测试触及其他测试持有的全局终端。"""
scope.seal()
closed.append(scope)
return True
monkeypatch.setattr(orchestrator, "close_terminal_scope", close)
monkeypatch.setattr(session, "close_terminal_scope", close)
return closed
@pytest_asyncio.fixture
async def manager(closed_scopes) -> AsyncIterator[AgentManager]:
"""使用真实消息队列并为每个用例独立收口 worker记忆边界保持无 I/O。"""
memory = AsyncMock()
memory.clear_memory = Mock()
owner = AgentManager(memory=memory)
owner._accepting_tasks = True
try:
yield owner
finally:
await owner.close()
@pytest.mark.asyncio
async def test_conversation_scope_survives_turn_and_graph_rebuild(monkeypatch, closed_scopes) -> None:
"""正常多轮、图失效均复用同一身份,调用结束恢复上层异步上下文。"""
agent = MoviePilotAgent("conversation", user_id="alice")
seen = []
async def process(_message: str, **_kwargs) -> str:
"""在推理入口读取实际 ContextVar并使出执行权检验异步传播。"""
seen.append(require_terminal_scope())
await asyncio.sleep(0)
assert require_terminal_scope() is seen[-1]
return "完成"
monkeypatch.setattr(agent, "_process", process)
outer = TerminalScope("operator", "parent", "operator")
with bind_terminal_scope(outer):
assert await agent.process("启动") == "完成"
assert current_terminal_scope() is outer
assert await agent._invalidate_cached_agent() is True
assert await agent.process("继续") == "完成"
assert current_terminal_scope() is outer
assert seen == [agent._terminal_scope, agent._terminal_scope]
assert not closed_scopes
assert await agent.cleanup() is True
with pytest.raises(TerminalAccessError):
await agent.process("清理后不得重新启动")
@pytest.mark.asyncio
async def test_no_user_agent_can_reason_but_cannot_enter_terminal(monkeypatch) -> None:
"""无可信用户的内部 Agent 可执行非终端逻辑,不能获得默认 api_user 身份。"""
agent = MoviePilotAgent("anonymous")
async def process(_message: str, **_kwargs) -> str:
"""在真正的 wrapper 中验证缺身份拒绝点。"""
assert current_terminal_scope().user_id == ""
with pytest.raises(TerminalAccessError):
require_terminal_scope()
return "无需终端"
monkeypatch.setattr(agent, "_process", process)
assert await agent.process("纯推理") == "无需终端"
with pytest.raises(TerminalAccessError):
await agent.process("冒用", terminal_scope=TerminalScope("alice", "run", "scheduled"))
@pytest.mark.asyncio
async def test_cleanup_seals_all_scopes_and_retries_both_resource_owners(monkeypatch) -> None:
"""子代理清理失败不能跳过终端;终端未收敛则保持精确归属对象供重试。"""
agent = MoviePilotAgent("conversation", user_id="alice")
scheduled = TerminalScope("alice", "run-1", "scheduled")
agent._scheduled_terminal_scopes.add(scheduled)
children = AsyncMock(side_effect=[False, True, True])
monkeypatch.setattr(agent, "_invalidate_cached_agent", children)
calls = []
async def close(scope: TerminalScope) -> bool:
"""检查任何第一次异步清理前全部身份均已同步封口。"""
assert agent._terminal_scope.closed and scheduled.closed
calls.append(scope)
return scope is agent._terminal_scope or children.await_count == 3
monkeypatch.setattr(orchestrator, "close_terminal_scope", close)
assert await agent.cleanup() is False
assert calls == [agent._terminal_scope, scheduled]
assert scheduled in agent._scheduled_terminal_scopes
assert await agent.cleanup() is False
assert await agent.cleanup() is True
assert not agent._scheduled_terminal_scopes
@pytest.mark.asyncio
async def test_worker_keeps_conversation_and_distinguishes_scheduled_runs(
manager, monkeypatch, closed_scopes,
) -> None:
"""真实队列相同会话的交互轮次共享 owner两次 scheduled run 各自独立收口。"""
seen = []
async def process(_self, message: str, **_kwargs) -> str:
"""在生产 process wrapper 内记录当前实际任务身份。"""
seen.append(require_terminal_scope())
return message
monkeypatch.setattr(MoviePilotAgent, "_process", process)
for message, run_id in [("交互1", None), ("定时1", "run-1"), ("定时2", "run-2"), ("交互2", None)]:
assert await manager.process_message(
session_id="shared", user_id="alice", message=message,
wait_for_completion=True, scheduled_run_id=run_id,
) == message
assert seen[0] is seen[3]
assert seen[1] is not seen[2] and seen[1] is not seen[0]
assert [scope.task_id for scope in seen[1:3]] == ["run-1", "run-2"]
assert [scope.kind for scope in seen[1:3]] == ["scheduled", "scheduled"]
assert seen[1].closed and seen[2].closed
assert not seen[0].closed
assert closed_scopes == seen[1:3]
assert not manager.active_agents["shared"]._scheduled_terminal_scopes
@pytest.mark.asyncio
async def test_cancelled_queued_run_is_sealed_and_never_enters_reasoning(manager, monkeypatch) -> None:
"""取消等待中的定时 run 不影响当前交互推理,也不能稍后从队列复活。"""
started = asyncio.Event()
release = asyncio.Event()
messages = []
async def process(_self, message: str, **_kwargs) -> str:
"""仅阻塞第一条交互请求,保留后续定时请求的真实排队窗口。"""
messages.append(message)
if message == "交互":
started.set()
await release.wait()
return message
monkeypatch.setattr(MoviePilotAgent, "_process", process)
interactive = asyncio.create_task(manager.process_message(
session_id="shared", user_id="alice", message="交互", wait_for_completion=True,
))
await asyncio.wait_for(started.wait(), 2)
scheduled = asyncio.create_task(manager.process_message(
session_id="shared", user_id="alice", message="取消的定时", scheduled_run_id="run-1",
wait_for_completion=True,
))
await asyncio.sleep(0)
queued = manager._session_queues["shared"]._queue[0]
scheduled.cancel()
with pytest.raises(asyncio.CancelledError):
await scheduled
assert queued.terminal_scope.closed
assert not manager.active_agents["shared"]._terminal_scope.closed
release.set()
assert await interactive == "交互"
await asyncio.wait_for(manager._session_queues["shared"].join(), 2)
assert messages == ["交互"]
@pytest.mark.asyncio
async def test_cancel_active_wait_closes_same_scope_and_retains_failed_cleanup(
manager, monkeypatch,
) -> None:
"""等待者取消先封活动 scope不收敛时对象留在 Agentworker 最终再尝试回收。"""
started = asyncio.Event()
release = asyncio.Event()
seen = []
closes = []
async def process(_self, _message: str, **_kwargs) -> str:
"""模拟已进入模型且稍后才结束的执行,不让 caller 取消传播到会话 worker。"""
seen.append(require_terminal_scope())
started.set()
await release.wait()
with pytest.raises(TerminalAccessError):
require_terminal_scope()
return "收尾"
async def close(scope: TerminalScope) -> bool:
"""第一次终端收口失败,实际 worker 结束后再报告已收敛。"""
scope.seal()
closes.append(scope)
return release.is_set()
monkeypatch.setattr(MoviePilotAgent, "_process", process)
monkeypatch.setattr(orchestrator, "close_terminal_scope", close)
scheduled = asyncio.create_task(manager.process_message(
session_id="shared", user_id="alice", message="定时", scheduled_run_id="run-1",
wait_for_completion=True,
))
await asyncio.wait_for(started.wait(), 2)
scheduled.cancel()
with pytest.raises(asyncio.CancelledError):
await scheduled
agent = manager.active_agents["shared"]
assert seen[0].closed
assert closes == seen
assert seen[0] in agent._scheduled_terminal_scopes
assert not agent._terminal_scope.closed
release.set()
await asyncio.wait_for(manager._session_queues["shared"].join(), 2)
assert closes == [seen[0], seen[0]]
assert not agent._scheduled_terminal_scopes
@pytest.mark.asyncio
async def test_switch_user_retains_old_agent_until_cleanup_converges(manager, monkeypatch) -> None:
"""同 session 换主体必须等待旧资源收口,不能只修改旧 Agent 的 user_id。"""
old = MoviePilotAgent("shared", user_id="alice")
manager.active_agents["shared"] = old
cleanup = AsyncMock(side_effect=[False, True])
monkeypatch.setattr(old, "cleanup", cleanup)
process = AsyncMock(return_value="bob 完成")
monkeypatch.setattr(MoviePilotAgent, "_process", process)
task = _MessageTask(session_id="shared", user_id="bob", message="新用户")
with pytest.raises(AgentManagerUnavailableError):
await manager._process_message_internal(task)
assert manager.active_agents["shared"] is old
assert old.user_id == "alice"
process.assert_not_awaited()
assert await manager._process_message_internal(task) == "bob 完成"
replacement = manager.active_agents["shared"]
assert replacement is not old and replacement.user_id == "bob"
assert replacement._terminal_scope is not old._terminal_scope
@pytest.mark.asyncio
async def test_real_worker_terminal_survives_stop_and_other_user_cleanup(monkeypatch) -> None:
"""真实 pipe 经生产 worker 装配 owner停止推理保留进程换主体清理才回收。"""
terminals = _TerminalSessionManager()
monkeypatch.setattr(terminal, "terminal_session_manager", terminals)
memory = AsyncMock()
memory.clear_memory = Mock()
owner = AgentManager(memory=memory)
owner._accepting_tasks = True
ready = asyncio.Event()
handles = []
argv = [sys.executable, "-u", "-c", "import sys; print('READY', flush=True); sys.stdin.read()"]
command = subprocess.list2cmdline(argv) if os.name == "nt" else "exec " + shlex.join(argv)
async def process(_self, message: str, **_kwargs) -> str:
"""模型边界使用本地固定程序,其余执行、授权和资源回收均为真实实现。"""
if message == "启动并继续推理":
payload = await terminals.start(
command=command, use_pty=False, yield_time_ms=1000, since_offset=0,
)
handles.append(payload["session_id"])
ready.set()
await asyncio.Event().wait()
if message == "新用户":
return "新用户就绪"
return (await terminals.read(session_id=handles[0], since_seq=0, since_offset=0))["output"]
monkeypatch.setattr(MoviePilotAgent, "_process", process)
execution = asyncio.create_task(owner.process_message(
session_id="alice-session", user_id="alice", message="启动并继续推理", wait_for_completion=True,
))
try:
await asyncio.wait_for(ready.wait(), 5)
alice = owner.active_agents["alice-session"]
process_record = terminals._sessions[handles[0]]
assert await owner.stop_current_task("alice-session") is True
with pytest.raises(asyncio.CancelledError):
await execution
assert not alice._terminal_scope.closed
assert process_record.process.returncode is None
assert await alice._invalidate_cached_agent() is True
assert "READY" in await owner.process_message(
session_id="alice-session", user_id="alice", message="读取", wait_for_completion=True,
)
with pytest.raises(TerminalAccessError):
await owner.process_message(
session_id="bob-session", user_id="bob", message="读取", wait_for_completion=True,
)
await owner.clear_session("bob-session", "bob")
assert process_record.process.returncode is None
assert await owner.process_message(
session_id="alice-session", user_id="bob", message="新用户", wait_for_completion=True,
) == "新用户就绪"
assert alice._terminal_scope.closed
assert process_record.process.returncode is not None
assert handles[0] not in terminals._sessions
finally:
if not execution.done():
execution.cancel()
await asyncio.gather(execution, return_exceptions=True)
await owner.close()
await terminals.close()