mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-14 04:12:05 +00:00
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 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
236 lines
10 KiB
Python
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]]",
|
|
}
|