Files
odysseus/tests/test_fork_session_metadata.py
T
Mazen Tamer Salah 5198516979 fix(sessions): copy message metadata when forking a session (#3409)
fork_session passed each source message's metadata dict by reference into the
new session. add_message() -> _persist_message() stamps _db_id (and timestamp)
onto that dict in place, so persisting the fork overwrote the SOURCE messages'
_db_id with the forked rows' ids — silently breaking edit/delete-by-id on the
original conversation.

Copy the metadata dict per message so the fork and source no longer alias.

Adds tests/test_fork_session_metadata.py asserting the source session's
message metadata is unchanged after a fork.
2026-06-08 20:49:15 +02:00

85 lines
2.8 KiB
Python

"""Forking a session must not mutate the source session's messages.
ChatMessage.metadata is a dict. add_message() -> _persist_message() stamps
_db_id (and timestamp) onto that dict in place. The fork handler used to pass
the source message's metadata dict by reference into the new session, so
persisting the fork rewrote the SOURCE messages' _db_id — breaking
edit/delete-by-id on the original conversation. The fork must copy the dict.
"""
import asyncio
from types import SimpleNamespace
from core.models import ChatMessage
import routes.history_routes as mod
class _FakeSession:
def __init__(self, name="", owner=None):
self.name = name
self.owner = owner
self.endpoint_url = ""
self.model = ""
self.history = []
def add_message(self, message):
# Mirror _persist_message: stamp the in-memory message's metadata.
if message.metadata is None:
message.metadata = {}
message.metadata["_db_id"] = f"new-{len(self.history)}"
self.history.append(message)
class _FakeSessionManager:
def __init__(self, source):
self.sessions = {"src-id": source}
self.created = None
def create_session(self, session_id=None, name=None, endpoint_url=None,
model=None, rag=False, owner=None):
self.created = _FakeSession(name=name, owner=owner)
return self.created
def save_sessions(self):
pass
def _fork_handler(router):
for route in router.routes:
if "/fork" in getattr(route, "path", "") and "POST" in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError("fork route not found")
def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None)
source = _FakeSession(name="Original", owner="alice")
source.history = [
ChatMessage("user", "hi", {"_db_id": "src-0"}),
ChatMessage("assistant", "yo", {"_db_id": "src-1"}),
]
sm = _FakeSessionManager(source)
req = SimpleNamespace()
async def _json():
return {"keep_count": 2}
req.json = _json
router = mod.setup_history_routes(sm)
fork = _fork_handler(router)
result = asyncio.run(fork(request=req, session_id="src-id"))
assert result["status"] == "ok"
assert result["kept"] == 2
# The forked session got its own metadata dicts...
new_session = sm.created
assert new_session.history[0].metadata is not source.history[0].metadata
assert new_session.history[1].metadata is not source.history[1].metadata
# ...and the source session's _db_id values are untouched.
assert source.history[0].metadata["_db_id"] == "src-0"
assert source.history[1].metadata["_db_id"] == "src-1"