"""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 get_session(self, session_id): # Fork looks the source up through get_session — the hydration seam — # so a session only present in the DB still forks a real transcript. return self.sessions[session_id] def create_session(self, session_id=None, name=None, endpoint_url=None, model=None, rag=False, owner=None, endpoint_id=None): self.created = _FakeSession(name=name, owner=owner) self.created.endpoint_id = endpoint_id 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.endpoint_id = "chatgpt-account-b" 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 assert sm.created.endpoint_id == "chatgpt-account-b" # 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" def _fork_with(monkeypatch, body, db_ids): """Fork a 4-message source whose DB rows have the given ids.""" monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None) source = _FakeSession(name="Long chat") for i, role in enumerate(["user", "assistant", "user", "assistant"]): source.history.append(ChatMessage(role=role, content=f"m{i}", metadata={"_db_id": db_ids[i]})) manager = _FakeSessionManager(source) rows = [SimpleNamespace(id=i) for i in db_ids] class _Query: def filter(self, *a): return self def order_by(self, *a): return self def all(self): return rows class _DB: def query(self, *a): return _Query() def close(self): pass monkeypatch.setattr(mod, "SessionLocal", lambda: _DB()) router = mod.setup_history_routes(manager) class _Req: async def json(self): return body asyncio.run(_fork_handler(router)(_Req(), "src-id")) return [m.content for m in manager.created.history] def test_fork_before_a_message_id_ignores_a_stale_keep_count(monkeypatch): copied = _fork_with(monkeypatch, {"before_msg_id": "c", "keep_count": 0}, ["a", "b", "c", "d"]) assert copied == ["m0", "m1"] def test_fork_through_a_message_id_includes_it(monkeypatch): copied = _fork_with(monkeypatch, {"through_msg_id": "b", "keep_count": 0}, ["a", "b", "c", "d"]) assert copied == ["m0", "m1"]