diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index aed6717bd36..89b2a82942d 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -1410,32 +1410,24 @@ class RecallGuardMiddleware(InboundMiddleware): logger.warning("[%s] Recall: failed to resolve session: %s", adapter.name, exc) return - # Load transcript from canonical store (state.db). - # - # Branch A1 below tries to match the recalled message by its platform - # `message_id`. state.db does NOT preserve `message_id` (only its own - # autoincrement primary key), so A1 will not match for any message - # persisted post-DB-canonical (i.e. all messages going forward). Recall - # falls through to A2 (content match) or B (system redaction note), both - # of which work DB-only. - # - # TODO: add a `platform_message_id` column to state.db messages to restore - # exact-id matching. Tracked separately. + # Load transcript from canonical store (state.db). See Branch A below + # for why we can no longer match by platform `message_id`. try: transcript = store.load_transcript(sid) except Exception as exc: logger.warning("[%s] Recall: failed to load transcript: %s", adapter.name, exc) return - # Branch A: redact — try message_id first, then content fallback. - # Observed messages have message_id; agent-processed @bot messages - # only have content (run.py doesn't write message_id to transcript). + # Branch A: content-match redaction. state.db does NOT preserve the + # platform `message_id` (only its own autoincrement primary key), so we + # cannot redact by exact id. Match by content instead. Most yuanbao + # recalls carry the recalled text via `recalled_content`, which is + # sufficient for any non-duplicate message. + # + # TODO: add a `platform_message_id` column to state.db messages to + # restore exact-id matching. Tracked separately. target = None - for entry in transcript: - if entry.get("message_id") == recalled_id: - target = entry - break - if target is None and recalled_content: + if recalled_content: for entry in transcript: if entry.get("role") == "user" and entry.get("content") == recalled_content: target = entry @@ -1444,7 +1436,7 @@ class RecallGuardMiddleware(InboundMiddleware): target["content"] = cls._REDACTED try: store.rewrite_transcript(sid, transcript) - logger.info("[%s] Recall: redacted msg_id=%s (branch A)", adapter.name, recalled_id) + logger.info("[%s] Recall: redacted msg_id=%s (branch A: content match)", adapter.name, recalled_id) except Exception as exc: logger.warning("[%s] Recall: rewrite_transcript failed: %s", adapter.name, exc) return diff --git a/tests/gateway/platforms/test_yuanbao_recall_db_only.py b/tests/gateway/platforms/test_yuanbao_recall_db_only.py index da697f01931..f54a5f34679 100644 --- a/tests/gateway/platforms/test_yuanbao_recall_db_only.py +++ b/tests/gateway/platforms/test_yuanbao_recall_db_only.py @@ -1,10 +1,10 @@ -"""Yuanbao recall: branch A2 (content-match) works without JSONL message_id.""" +"""Yuanbao recall: branch A (content-match) works against DB-only transcripts.""" from gateway.session import SessionStore from gateway.config import GatewayConfig -def test_recall_falls_through_to_content_match_without_message_id(tmp_path, monkeypatch): - """When transcript has no message_id field, A2 content-match still works. +def test_recall_content_match_finds_target_in_db_transcript(tmp_path, monkeypatch): + """state.db doesn't preserve message_id, so recall uses content-match. Pin DEFAULT_DB_PATH to tmp_path so SessionDB() can't write to the real ~/.hermes/state.db. (Module-level constant snapshot, see test_load_transcript_db_only.) @@ -20,12 +20,11 @@ def test_recall_falls_through_to_content_match_without_message_id(tmp_path, monk store.append_to_transcript(sid, {"role": "user", "content": "sensitive content", "timestamp": 1.0}) store.append_to_transcript(sid, {"role": "assistant", "content": "ack", "timestamp": 2.0}) - # The post-PR state: load_transcript returns DB-only, no message_id field. + # DB-only history carries no platform message_id (PR #29211 dropped that path). history = store.load_transcript(sid) - assert all("message_id" not in msg for msg in history), \ - "DB-only history should not carry message_id" + assert all("message_id" not in msg for msg in history) - # Branch A2: content match should still find the message + # Branch A: content match finds the target row that recall would redact. target = next((m for m in history if m.get("role") == "user" and m.get("content") == "sensitive content"), None) assert target is not None diff --git a/tests/gateway/test_retry_replacement.py b/tests/gateway/test_retry_replacement.py index 571485caac2..3a6d0665875 100644 --- a/tests/gateway/test_retry_replacement.py +++ b/tests/gateway/test_retry_replacement.py @@ -11,7 +11,12 @@ from gateway.session import SessionStore @pytest.mark.asyncio -async def test_gateway_retry_replaces_last_user_turn_in_transcript(tmp_path): +async def test_gateway_retry_replaces_last_user_turn_in_transcript(tmp_path, monkeypatch): + # Pin DEFAULT_DB_PATH so SessionDB() doesn't write to the real ~/.hermes/state.db. + # (Module-level constant snapshot, see test_load_transcript_db_only.) + import hermes_state + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + config = GatewayConfig() store = SessionStore(sessions_dir=tmp_path, config=config)