ReMe/tests/unit/test_session_image_assets.py
WQS c1de31ab2c
Some checks failed
CI and Release / Docker / Build and test / amd64 (push) Has been cancelled
CI and Release / Docker / Build and test / arm64 (push) Has been cancelled
CI / Python packages / Build and verify distributions (push) Has been cancelled
CI / Python quality / Pre-commit (push) Has been cancelled
CI / Python quality / GitHub Actions (push) Has been cancelled
CI / Python tests / Unit Tests - py3.11 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.12 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.13 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.14 (push) Has been cancelled
CI / Windows / CLI smoke - py3.11 (push) Has been cancelled
Deploy / Documentation / Build documentation (push) Has been cancelled
Security / CodeQL / Analyze javascript-typescript (push) Has been cancelled
Security / CodeQL / Analyze python (push) Has been cancelled
Deploy / Documentation / deploy (push) Has been cancelled
CI and Release / Docker / Publish multi-platform tags (push) Has been cancelled
feat(auto-memory): save session images with source links (#574)
* feat(auto-memory): save session images with contextual source links

* fix(auto-memory): validate image provenance before persistence

* refactor(auto-memory): keep image attachment helpers in the memory step

* fix(auto-memory): preserve main session persistence order

* refactor(auto-memory): simplify image reuse and source frontmatter
2026-10-09 17:42:13 +08:00

236 lines
10 KiB
Python

"""Session attachments preserve bytes, identity, file boundaries and user links."""
# pylint: disable=protected-access,missing-function-docstring
import asyncio
import base64
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
from agentscope.message import Base64Source, DataBlock, Msg, TextBlock, URLSource
import frontmatter
import httpx
import pytest
from reme.components.runtime_context import RuntimeContext
from reme.steps.evolve.auto_memory import AutoMemoryStep
from reme.steps.evolve.auto_memory import _save_session_images as save_session_images
from reme.steps.file_io.write import WriteStep
def _prepared(*sources, message_id="message-a"):
content = [TextBlock(text="before")]
for source in sources:
if not isinstance(source, URLSource):
source = Base64Source(media_type="image/png", data=base64.b64encode(source).decode())
content.append(DataBlock(id="same-block-id", source=source))
content.append(TextBlock(text="after"))
original = Msg(id=message_id, name="Alice", role="user", content=content)
prepared = original.model_copy(deep=True)
images = {}
for index, block in enumerate(prepared.content):
if isinstance(block, DataBlock):
marker = f"__image_{index}__"
images[marker] = block
prepared.content[index] = TextBlock(text=marker)
return original, [prepared], images
@pytest.fixture(name="memory_step")
def session_memory_step(tmp_path):
step = AutoMemoryStep()
step.file_store = SimpleNamespace(workspace_path=tmp_path)
step.context = RuntimeContext()
return step
@pytest.mark.asyncio
async def test_bytes_positions_and_existing_images_are_reused_without_reading(tmp_path, monkeypatch):
original, messages, images = _prepared(b"first image", b"second image")
snapshot = original.model_dump()
sources = await save_session_images(tmp_path, "sessions", "chat", messages, images)
assert len(sources) == 2
paths = [tmp_path / source[2:-2] for source in sources]
assert [path.read_bytes() for path in paths] == [b"first image", b"second image"]
assert [path.name.rsplit("-", 1)[1] for path in paths] == ["1.png", "2.png"]
assert messages[0].content[1].text == f"Image source: {sources[0]}\n__image_1__"
assert messages[0].content[2].text == f"Image source: {sources[1]}\n__image_2__"
assert original.model_dump() == snapshot
assert [block.model_dump() for block in images.values()] == snapshot["content"][1:3]
mtimes = [path.stat().st_mtime_ns for path in paths]
_, replay, replay_images = _prepared(b"different first image", b"different second image")
forbidden = Mock(side_effect=AssertionError("Existing attachments must not be decoded or read"))
with monkeypatch.context() as patch:
patch.setattr(base64, "b64decode", forbidden)
patch.setattr(Path, "read_bytes", forbidden)
assert await save_session_images(tmp_path, "sessions", "chat", replay, replay_images) == sources
forbidden.assert_not_called()
assert [path.read_bytes() for path in paths] == [b"first image", b"second image"]
assert [path.stat().st_mtime_ns for path in paths] == mtimes
_, extended, extended_images = _prepared(b"different first image", b"different second image", b"third image")
decoder = Mock(wraps=base64.b64decode)
with monkeypatch.context() as patch:
patch.setattr(base64, "b64decode", decoder)
patch.setattr(Path, "read_bytes", forbidden)
extended_sources = await save_session_images(tmp_path, "sessions", "chat", extended, extended_images)
decoder.assert_called_once_with(extended_images["__image_3__"].source.data, validate=True)
assert extended_sources[:2] == sources and len(extended_sources) == 3
assert (tmp_path / extended_sources[2][2:-2]).read_bytes() == b"third image"
assert [path.stat().st_mtime_ns for path in paths] == mtimes
@pytest.mark.asyncio
@pytest.mark.parametrize(
"media_type,suffix",
[
("image/png", ".png"),
("image/jpeg", ".jpg"),
("image/webp", ".webp"),
("image/gif", ".gif"),
("image/bmp", ".bmp"),
("image/tiff", ".tiff"),
("image/heic", ".heic"),
("image/avif", ".bin"),
],
)
async def test_image_suffixes_preserve_bytes_including_unknown_types(tmp_path, media_type, suffix):
_, messages, images = _prepared(b"unchanged bytes")
images["__image_1__"].source.media_type = media_type
sources = await save_session_images(tmp_path, "session", "chat", messages, images)
path = tmp_path / sources[0][2:-2]
assert path.suffix == suffix and path.read_bytes() == b"unchanged bytes"
@pytest.mark.asyncio
@pytest.mark.parametrize("message_id", ["../../outside", "A", "a", "图像/a%"])
async def test_message_id_is_encoded_not_used_as_a_path(tmp_path, message_id):
_, messages, images = _prepared(b"image", message_id=message_id)
sources = await save_session_images(tmp_path, "session", "chat", messages, images)
path = tmp_path / sources[0][2:-2]
encoded = path.name.removeprefix("msg-").split("-image-", 1)[0]
assert bytes.fromhex(encoded).decode() == message_id
assert path.parent == tmp_path / "session/images/chat"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"session_dir,session_id",
[("session", "chat#1"), ("session", "[chat]"), ("sessions#1", "chat"), ("[sessions]", "chat")],
)
async def test_unlinkable_attachment_paths_fail_before_writing(tmp_path, session_dir, session_id):
_, messages, images = _prepared(b"image")
with pytest.raises(ValueError, match="cannot be linked"):
await save_session_images(tmp_path, session_dir, session_id, messages, images)
assert not list(tmp_path.iterdir())
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", ["base64", "denied", "directory", "symlink", "traversal"])
async def test_invalid_inputs_are_checked_before_any_image_write(tmp_path, failure):
_, messages, images = _prepared(b"first", b"second")
session_dir = "session"
allowed_paths = None
second = tmp_path / f"session/images/chat/msg-{'message-a'.encode().hex()}-image-2.png"
if failure == "base64":
images["__image_2__"].source.data = "invalid base64!"
elif failure == "denied":
allowed_paths = ["unrelated"]
elif failure == "directory":
second.mkdir(parents=True)
elif failure == "symlink":
(tmp_path / "session").symlink_to(tmp_path.parent, target_is_directory=True)
else:
session_dir = "../outside"
with pytest.raises((ValueError, PermissionError)):
await save_session_images(tmp_path, session_dir, "chat", messages, images, allowed_paths)
assert messages[0].content[1].text == "__image_1__"
assert not list(tmp_path.glob("session/images/chat/*-image-1.png"))
@pytest.mark.asyncio
async def test_http_sources_remain_urls_without_local_io(tmp_path, monkeypatch):
def fail_network(*_args, **_kwargs):
pytest.fail("Auto Memory must not download image URLs")
monkeypatch.setattr(httpx.Client, "request", fail_network)
monkeypatch.setattr(httpx.AsyncClient, "request", fail_network)
url = "https://example.com/image.png?token=original"
_, messages, images = _prepared(URLSource(url=url, media_type="image/png"))
assert await save_session_images(tmp_path, "session", "chat", messages, images) == [url]
assert messages[0].content[1].text == f"Image source: {url}\n__image_1__"
assert not list(tmp_path.iterdir())
@pytest.mark.asyncio
async def test_source_links_preserve_body_unknown_links_and_noop_mtime(tmp_path, memory_step):
path = tmp_path / "note.md"
path.write_text("---\nsource_images: ['[[manual.md]]']\nuser_owned: keep\n---\n\n# Body\n\nKeep this text.")
before = frontmatter.loads(path.read_text())
await memory_step._ensure_session_frontmatter("note.md", "chat", ["[[new.png]]", "[[new.png]]", "[[manual.md]]"])
after = frontmatter.loads(path.read_text())
assert after.content == before.content
assert after.metadata == {
"source_images": ["[[manual.md]]", "[[new.png]]"],
"user_owned": "keep",
"session_id": "chat",
"source_conversation": "[[session/dialog/chat.jsonl]]",
}
mtime = path.stat().st_mtime_ns
await memory_step._ensure_session_frontmatter("note.md", "chat", ["[[new.png]]"])
assert path.stat().st_mtime_ns == mtime
@pytest.mark.asyncio
@pytest.mark.parametrize(
"existing,value",
[(True, "manual"), (True, None), (True, [3]), (False, "[[image.png]]"), (False, [None])],
)
async def test_invalid_sources_do_not_rewrite_note(tmp_path, memory_step, existing, value):
path = tmp_path / "note.md"
path.write_text(frontmatter.dumps(frontmatter.Post("User body.", **({"source_images": value} if existing else {}))))
before = path.read_bytes()
with pytest.raises(ValueError, match="list of strings"):
await memory_step._ensure_session_frontmatter("note.md", "chat", ["[[new.png]]"] if existing else value)
assert path.read_bytes() == before
@pytest.mark.asyncio
async def test_source_frontmatter_rereads_after_concurrent_native_write(tmp_path, monkeypatch, memory_step):
monkeypatch.chdir(tmp_path)
write_module = importlib.import_module("reme.steps.file_io.write")
native_write = write_module.write_file_safe
entered, release = asyncio.Event(), asyncio.Event()
async def delayed_write(*args, **kwargs):
entered.set()
await release.wait()
await native_write(*args, **kwargs)
monkeypatch.setattr(write_module, "write_file_safe", delayed_write)
path = tmp_path / "note.md"
path.write_text("Old content")
writer = WriteStep(file_store=SimpleNamespace(workspace_path=tmp_path))
context = RuntimeContext(
path="note.md",
content="New user content",
metadata={"source_images": ["[[manual.md]]"], "user_owned": 7},
)
writing = asyncio.create_task(writer(context))
await asyncio.wait_for(entered.wait(), timeout=5)
merging = asyncio.create_task(memory_step._ensure_session_frontmatter("note.md", "chat", ["[[image.png]]"]))
await asyncio.sleep(0)
assert not merging.done()
release.set()
await asyncio.wait_for(asyncio.gather(writing, merging), timeout=5)
assert context.response.success
post = frontmatter.loads(path.read_text())
assert post.content == "New user content"
assert post.metadata == {
"source_images": ["[[manual.md]]", "[[image.png]]"],
"user_owned": 7,
"session_id": "chat",
"source_conversation": "[[session/dialog/chat.jsonl]]",
}