diff --git a/routes/history_routes.py b/routes/history_routes.py index 558663f..9efaa94 100644 --- a/routes/history_routes.py +++ b/routes/history_routes.py @@ -58,7 +58,7 @@ def setup_history_routes(session_manager) -> APIRouter: .all() ) import json as _json - history_dict = [] + db_history = [] for m in db_messages: entry = {"role": m.role, "content": m.content} meta = {} @@ -71,12 +71,19 @@ def setup_history_routes(session_manager) -> APIRouter: meta["timestamp"] = m.timestamp.isoformat() + "Z" if meta: entry["metadata"] = meta - history_dict.append(entry) - if history_dict: + db_history.append(entry) + if db_history: + # Rebuild in-memory history from the full set so hidden + # messages (e.g. compaction summaries) are kept for AI context. session.history = [ ChatMessage(role=m["role"], content=m["content"], metadata=m.get("metadata")) - for m in history_dict + for m in db_history ] + # Response excludes hidden messages, matching the in-memory path. + history_dict = [ + m for m in db_history + if not (m.get("metadata") or {}).get("hidden") + ] except Exception as e: logger.error(f"DB fallback failed for {session_id}: {e}") finally: diff --git a/tests/test_history_db_fallback_hidden.py b/tests/test_history_db_fallback_hidden.py new file mode 100644 index 0000000..7e43d16 --- /dev/null +++ b/tests/test_history_db_fallback_hidden.py @@ -0,0 +1,38 @@ +"""Regression: the DB fallback in get_session_history must hide the same +messages the in-memory path hides. + +The in-memory branch skips messages whose metadata has ``hidden`` (e.g. +compaction summaries that are kept for AI context but not shown to the user). +The DB fallback (taken when the in-memory history is empty, e.g. after a +restart) built the client response from every DB row with no such filter, so +hidden messages leaked to the client on DB-served sessions. The rebuilt +in-memory ``session.history`` must still keep them, though, so only the response +is filtered. + +get_session_history depends on the DB, the session manager and a FastAPI +request, so this pins the regression at the source level (as other route tests +in this repo do). +""" +import ast +from pathlib import Path + +SRC = Path(__file__).resolve().parent.parent / "routes" / "history_routes.py" + + +def _function_source(src_text, name): + tree = ast.parse(src_text) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name: + return ast.get_source_segment(src_text, node) + raise AssertionError(f"{name} not found in {SRC}") + + +def test_db_fallback_filters_hidden_from_response(): + src = _function_source(SRC.read_text(), "get_session_history") + marker = "load from DB" + assert marker in src, "expected the DB fallback block in get_session_history" + db_section = src.split(marker, 1)[1] + assert "hidden" in db_section, ( + "the DB-fallback path must filter `hidden` messages from the response " + "to match the in-memory path" + )