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

308 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.
"""
工具截图仅供当前运行观察,持久化与日志不得复制像素或伪造用户消息。
"""
import base64
import json
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage, messages_from_dict, messages_to_dict
from app.agent.memory import MemoryManager
from app.agent.orchestrator import MoviePilotAgent
from app.agent.policy.sanitizer import sanitize_for_host, summarize_error, summarize_result
from app.agent.tools.result import (
EXECUTION_OUTCOME_KEY,
TOOL_OBSERVATION_MARKER,
inspect_tool_result,
is_image_content_block,
messages_for_persistence,
sanitize_tool_image_message,
)
_TOOL_PIXELS = base64.b64encode(b"private-browser-pixels" * 2000).decode("ascii")
_USER_PIXELS = base64.b64encode(b"original-user-attachment").decode("ascii")
def _image(kind="image_url", pixels=_TOOL_PIXELS):
"""
构造三种标准类别,只测试传递和去留,不把任意字节伪称有效 JPEG。
"""
url = f"data:image/jpeg;base64,{pixels}"
if kind == "image":
return {"type": "image", "base64": pixels, "mime_type": "image/jpeg", "width": 1280, "height": 720}
if kind == "input_image":
return {"type": "input_image", "image_url": url, "detail": "low"}
return {"type": "image_url", "image_url": {"url": url, "detail": "low"}}
def _tool_message(kind="image_url", outcome="succeeded"):
"""
保留截图来源、文本、执行状态和可能重复带图的 artifact。
"""
return ToolMessage(
id="source-message", name="browse_webpage", tool_call_id="screenshot-call",
content=[{"type": "text", "text": "页面来源 https://page.invalid;标题:确认表单"}, _image(kind)],
status="error" if outcome in {"failed", "unknown"} else "success",
artifact={"screenshot": _image(kind)},
additional_kwargs={EXECUTION_OUTCOME_KEY: outcome, "source": {"tab": "main", "title": "确认表单"}},
)
@pytest.mark.parametrize("kind", ["image", "image_url", "input_image"])
def test_tool_image_history_is_a_deep_copy_with_explicit_expiry(kind):
"""
图像去留不改变原图、文本、来源、调用归属和结果状态。
"""
original = _tool_message(kind)
before = deepcopy(original.model_dump())
sanitized = sanitize_tool_image_message(original)
assert sanitized is not original
assert original.model_dump() == before
assert not any(is_image_content_block(block) for block in sanitized.content)
assert "历史图像未保留" in str(sanitized.content)
assert sanitized.content[0] == original.content[0]
assert sanitized.additional_kwargs == original.additional_kwargs
assert sanitized.id == original.id and sanitized.name == original.name
assert sanitized.tool_call_id == original.tool_call_id and sanitized.status == original.status
assert sanitized.artifact is None
assert _TOOL_PIXELS[:80] not in json.dumps(sanitized.model_dump())
sanitized.content[0]["text"] = "仅修改持久化副本"
sanitized.additional_kwargs["source"]["title"] = "副本来源"
assert original.model_dump() == before
@pytest.mark.parametrize("outcome", ["succeeded", "failed", "unknown", "pending"])
def test_image_removal_does_not_change_execution_outcome(outcome):
"""
图像清理只改变像素保留,不把未知、失败或尚未完成的工具写成成功。
"""
original = _tool_message(outcome=outcome)
assert inspect_tool_result(sanitize_tool_image_message(original)) == inspect_tool_result(original)
def test_image_artifact_is_discarded_before_attempting_deep_copy():
"""
不持久化的运行资源不应阻断安全历史副本的生成。
"""
class RuntimeArtifact:
"""
模拟不能被复制的浏览器运行资源。
"""
def __deepcopy__(self, _memo):
"""
持久化应先去掉该资源,不能为了丢弃而先触发其复制。
"""
raise AssertionError("运行资源不可复制")
original = _tool_message()
original.artifact = RuntimeArtifact()
sanitized = sanitize_tool_image_message(original)
assert sanitized.artifact is None
assert original.artifact is not None
assert "历史图像未保留" in str(sanitized.content)
@pytest.mark.parametrize("message", [
HumanMessage(content=[_image(pixels=_USER_PIXELS)]),
AIMessage(content=[_image(pixels=_USER_PIXELS)]),
ToolMessage(content="普通文本结果", name="ordinary", tool_call_id="plain", artifact={"rows": [1, 2]}),
])
def test_real_user_attachments_and_non_image_tool_data_are_unchanged(message):
"""
用户附件、模型原有输出和普通工具 artifact 不属于截图清理范围。
"""
original = deepcopy(message.model_dump())
assert sanitize_tool_image_message(message) is message
assert message.model_dump() == original
@pytest.mark.parametrize("marker", [None, False, 1, "true", "True"])
def test_only_literal_host_true_marker_removes_temporary_observation(marker):
"""
值相似不代表宿主标记,普通用户内容及未知 marker 必须保留。
"""
user = HumanMessage(content=[_image(pixels=_USER_PIXELS)], additional_kwargs={TOOL_OBSERVATION_MARKER: marker})
assert messages_for_persistence([user]) == [user]
def test_temporary_human_observation_is_filtered_without_losing_tool_or_user():
"""
请求投影的临时 Human 图片不持久化,工具回执和真实用户输入仍保持顺序。
"""
user = HumanMessage(content=json.dumps({TOOL_OBSERVATION_MARKER: True}))
tool = _tool_message()
temporary = HumanMessage(content=[_image()], additional_kwargs={TOOL_OBSERVATION_MARKER: True})
assistant = AIMessage(content="已根据截图检查", additional_kwargs={TOOL_OBSERVATION_MARKER: True})
persisted = messages_for_persistence([user, tool, temporary, assistant])
assert len(persisted) == 3
assert persisted[0] is user and persisted[-1] is assistant
assert isinstance(persisted[1], ToolMessage)
assert persisted[1].tool_call_id == tool.tool_call_id
assert "历史图像未保留" in str(persisted[1].content)
assert is_image_content_block(tool.content[1])
@pytest.mark.parametrize("value", [
"image", ["image"], {"type": "video", "base64": "ordinary"},
{"type": "custom", "image_url": "unsupported"}, {"image": "untyped"},
{"type": ["image"]}, {"type": "IMAGE"},
])
def test_unknown_representations_do_not_become_image_content(value):
"""
只识别精确标准类别,普通字段名称不能自行启用模型视觉。
"""
assert is_image_content_block(value) is False
@pytest.mark.asyncio
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_memory_keeps_current_images_but_persists_text_only_tool_history(asynchronous):
"""
真实 MemoryManager 的同步和异步写入都只改 DB 副本,运行中缓存可继续观察截图。
"""
chat = SimpleNamespace(save_agent_messages=Mock())
persistence = SimpleNamespace(async_save_agent_messages=AsyncMock())
memory = MemoryManager(chat=chat, persistence=persistence)
user = HumanMessage(content=[{"type": "text", "text": "用户附件"}, _image(pixels=_USER_PIXELS)])
tool = _tool_message()
temporary = HumanMessage(content=[_image()], additional_kwargs={TOOL_OBSERVATION_MARKER: True})
messages = [user, tool, temporary]
original = messages_to_dict(messages)
if asynchronous:
await memory.async_save_agent_messages(session_id="image-session", user_id="owner", messages=messages)
serialized = persistence.async_save_agent_messages.call_args.kwargs["messages"]
else:
memory.save_agent_messages(session_id="image-session", user_id="owner", messages=messages)
serialized = chat.save_agent_messages.call_args.kwargs["messages"]
assert messages_to_dict(messages) == original
cached = memory.get_memory("image-session", "owner")
assert len(cached.messages) == 3 and is_image_content_block(cached.messages[1].content[1])
restored = messages_from_dict(serialized)
assert len(restored) == 2
assert restored[0].content == user.content
assert _USER_PIXELS in json.dumps(serialized)
assert _TOOL_PIXELS[:80] not in json.dumps(serialized)
assert "历史图像未保留" in str(restored[1].content)
assert restored[1].artifact is None
def test_reloading_persisted_images_applies_same_retention_contract():
"""
历史记录即使曾保存截图或临时投影,恢复时也不能重新成为视觉输入。
"""
user = HumanMessage(content=[_image(pixels=_USER_PIXELS)])
tool = _tool_message()
temporary = HumanMessage(content=[_image()], additional_kwargs={TOOL_OBSERVATION_MARKER: True})
serialized = messages_to_dict([user, tool, temporary])
original = deepcopy(serialized)
chat = SimpleNamespace(get_sync=Mock(return_value=SimpleNamespace(agent_messages=serialized)))
memory = MemoryManager(chat=chat)
restored = memory.get_agent_messages(session_id="history", user_id="owner")
assert len(restored) == 2
assert restored[0].content == user.content
assert "历史图像未保留" in str(restored[1].content)
assert serialized == original
assert memory.get_memory("history", "owner").messages == restored
def test_interruption_recovery_uses_placeholder_instead_of_broken_data_url():
"""
原有通用脱敏长度上限不能把截图截成仍声明为图像的坏 data URL。
"""
original = _tool_message(outcome="unknown")
sanitized = MoviePilotAgent._sanitize_recovery_message(original)
assert not any(is_image_content_block(block) for block in sanitized.content)
assert "历史图像未保留" in str(sanitized.content)
assert _TOOL_PIXELS[:80] not in str(sanitized.model_dump())
assert is_image_content_block(original.content[1])
assert sanitized.tool_call_id == original.tool_call_id
@pytest.mark.parametrize("kind", ["image", "image_url", "input_image"])
def test_interruption_recovery_preserves_user_images_and_sanitizes_only_text(kind):
"""
同一恢复消息中的用户附件保留完整字节,文字凭据仍清理,不能生成坏图块。
"""
original = HumanMessage(content=[{"type": "text", "text": "password=private-credential"},
_image(kind, pixels=_USER_PIXELS)])
before = messages_to_dict([original])
recovered = MoviePilotAgent._sanitize_recovery_message(original)
portable = MoviePilotAgent._portable_recovery_messages([recovered])
assert portable[0].content[1] == original.content[1]
assert _USER_PIXELS in json.dumps(messages_to_dict(portable))
assert "private-credential" not in json.dumps(messages_to_dict(portable))
recovered.content[1]["test_copy"] = True
assert messages_to_dict([original]) == before
@pytest.mark.parametrize("kind", ["image", "image_url", "input_image"])
def test_log_sanitizer_removes_structured_image_pixels_but_keeps_metadata(kind):
"""
真实图像块的像素必须完全移除,URL/标题文本和尺寸等元信息仍可诊断。
"""
payload = {"url": "https://page.invalid", "title": "确认表单", "result": [_image(kind)]}
original = deepcopy(payload)
sanitized = sanitize_for_host(payload)
assert payload == original
assert sanitized["url"] == payload["url"] and sanitized["title"] == payload["title"]
assert sanitized["result"][0]["type"] == kind
assert _TOOL_PIXELS[:80] not in json.dumps(sanitized)
if kind == "image_url":
assert sanitized["result"][0]["image_url"]["detail"] == "low"
if kind == "image":
assert sanitized["result"][0]["mime_type"] == "image/jpeg"
assert sanitized["result"][0]["width"] == 1280
def test_log_sanitizer_redacts_nested_image_source_and_artifact():
"""
嵌套 source、ToolMessage 和 artifact 的重复截图也不能绕过日志边界。
"""
message = _tool_message("image")
message.content[1] = {"type": "image", "source": {
"type": "base64", "media_type": "image/jpeg", "data": _TOOL_PIXELS,
}}
sanitized = sanitize_for_host(message)
assert _TOOL_PIXELS[:80] not in json.dumps(sanitized)
assert sanitized["content"][1]["source"]["media_type"] == "image/jpeg"
assert "确认表单" in str(sanitized)
@pytest.mark.parametrize("large", [False, True])
def test_browser_json_logs_never_keep_base64_prefix_after_text_truncation(large):
"""
超过 16KiB 的浏览器 JSON 走文本截断路径时仍应完整遮蔽像素前缀。
"""
pixels = _TOOL_PIXELS if large else _TOOL_PIXELS[:120]
payload = json.dumps({"url": "https://page.invalid", "title": "截图标题", "screenshot_base64": pixels,
"format": "jpeg", "api_key": "private-credential"}, ensure_ascii=False)
assert (len(payload) > 16 * 1024) is large
for output in (sanitize_for_host(payload), summarize_result(payload), summarize_error(ValueError(payload))):
assert _TOOL_PIXELS[:80] not in str(output)
assert "private-credential" not in str(output)
assert "截图标题" in str(output)
@pytest.mark.parametrize("large", [False, True])
def test_plain_text_data_url_is_removed_before_log_preview(large):
"""
数据 URL 无需 JSON 包装也必须去掉像素,不能只输出一段截断的 base64。
"""
pixels = _TOOL_PIXELS if large else _TOOL_PIXELS[:120]
output = sanitize_for_host(f"截图地址 data:image/jpeg;base64,{pixels} 后续说明")
assert _TOOL_PIXELS[:80] not in output
assert "截图地址" in output
def test_non_image_data_and_existing_credential_redaction_remain_distinct():
"""
普通 base64 数据不自动当作图片,现有凭据字段仍按原规则清理。
"""
payload = {"type": "ordinary", "base64": "non-image-encoded-data", "width": 12, "api_key": "secret"}
assert sanitize_for_host(payload) == {**payload, "api_key": "***"}