fix: history DB fallback returned hidden (compaction) messages to the client (#1726)
GET /api/history/{session_id} skips messages whose metadata has `hidden` (e.g.
compaction summaries kept for AI context, not shown to the user) on the
in-memory path. The DB fallback — used when the in-memory history is empty,
e.g. after a restart — built the response from every stored row with no such
filter, so hidden messages leaked to the client on DB-served sessions.
Filter `hidden` out of the response on the DB path too. The rebuilt in-memory
session.history still includes them, so AI context (the compaction summaries)
is preserved.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -58,7 +58,7 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
import json as _json
|
import json as _json
|
||||||
history_dict = []
|
db_history = []
|
||||||
for m in db_messages:
|
for m in db_messages:
|
||||||
entry = {"role": m.role, "content": m.content}
|
entry = {"role": m.role, "content": m.content}
|
||||||
meta = {}
|
meta = {}
|
||||||
@@ -71,12 +71,19 @@ def setup_history_routes(session_manager) -> APIRouter:
|
|||||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||||
if meta:
|
if meta:
|
||||||
entry["metadata"] = meta
|
entry["metadata"] = meta
|
||||||
history_dict.append(entry)
|
db_history.append(entry)
|
||||||
if history_dict:
|
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 = [
|
session.history = [
|
||||||
ChatMessage(role=m["role"], content=m["content"], metadata=m.get("metadata"))
|
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:
|
except Exception as e:
|
||||||
logger.error(f"DB fallback failed for {session_id}: {e}")
|
logger.error(f"DB fallback failed for {session_id}: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
38
tests/test_history_db_fallback_hidden.py
Normal file
38
tests/test_history_db_fallback_hidden.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user