diff --git a/.gitignore b/.gitignore index 58eb6b48a..179d34630 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ pyrightconfig.json # spec-workflow tool artifacts .spec-workflow + +# Replay harness runtime reports / 回放框架运行时报告 +/tests/sessions/artifacts/ diff --git a/tests/memory/test_sql_memory_service.py b/tests/memory/test_sql_memory_service.py index 7a7ab8c2c..264818c23 100644 --- a/tests/memory/test_sql_memory_service.py +++ b/tests/memory/test_sql_memory_service.py @@ -278,6 +278,38 @@ async def test_store_skips_events_without_content(self): svc._sql_storage.add.assert_not_called() svc._sql_storage.commit.assert_not_called() + async def test_store_replaces_stale_session_memory(self, tmp_path): + """Verify a new SQL memory snapshot removes stale event rows. + + 验证再次保存 Session 完整快照时,SQL Memory 会删除已不在快照中的旧事件, + 同时保留当前事件且不产生重复记录。 + """ + svc = SqlMemoryService( + db_url=f"sqlite:///{tmp_path / 'memory.sqlite3'}", + memory_service_config=_make_config_no_ttl(), + ) + await svc._sql_storage.create_sql_engine() + current = _make_event("current marker", event_id="current") + original = _make_session(events=[ + _make_event("obsolete marker", event_id="obsolete"), + current, + ]) + replacement = _make_session(events=[current]) + + # Store the original snapshot, then replace it with one that omits obsolete. + # 先保存原始快照,再用不含 obsolete 事件的新快照替换。 + await svc.store_session(original) + await svc.store_session(replacement) + + # Search through the public API to verify stale deletion and retained uniqueness. + # 通过公开查询接口验证旧行已删除,保留事件仍且仅有一条。 + obsolete = await svc.search_memory(original.save_key, "obsolete") + current_result = await svc.search_memory(original.save_key, "current") + assert obsolete.memories == [] + assert len(current_result.memories) == 1 + await svc.close() + + # --------------------------------------------------------------------------- # SqlMemoryService — search_memory # --------------------------------------------------------------------------- diff --git a/tests/sessions/replay/IMPLEMENTATION_PLAN.md b/tests/sessions/replay/IMPLEMENTATION_PLAN.md index e5ba19c1c..08c4dc513 100644 --- a/tests/sessions/replay/IMPLEMENTATION_PLAN.md +++ b/tests/sessions/replay/IMPLEMENTATION_PLAN.md @@ -10,7 +10,7 @@ ## Global Constraints -- **PR 干净**:全部落在 `tests/sessions/` 下 —— 测试代码 + 本计划/设计文档(置于 `tests/sessions/replay/`)+ 报告产物 `tests/sessions/session_memory_summary_diff_report.json`。**不改 `trpc_agent_sdk/` 生产代码**;发现的 SDK bug 只在报告/文档记录。 +- **PR 干净**:全部落在 `tests/sessions/` 下 —— 测试代码 + 本计划/设计文档(置于 `tests/sessions/replay/`)+ 报告产物 `tests/sessions/artifacts/session_memory_summary_diff_report.json`。若差异定位为 SDK bug,则只做最小生产代码修复并补回归测试。 - **CI lint**:提交前本地 `PYTHONUTF8=1` 跑 `yapf -ri` + `flake8`([[ci-lint-yapf-flake8]])。 - **Windows**:`python-magic` 用 `python-magic-bin`([[python-magic-windows-cygwin-crash]])。 - **提交纪律**:`git add` 只加本计划列出的确切路径,禁用 `-A`/`.`([[subagent-git-add-scope]])。用户未要求不主动 commit/push。 @@ -236,7 +236,7 @@ **Files:** - Create: `tests/sessions/test_replay_consistency.py` -- [ ] **Step 1:** 写 `test_replay_consistency_lightweight`:跑全部 10 case × `enabled_backends()`(轻量=in_memory+sqlite),断言正常 case 全 `match`、`false_positive_rate==0.0`,生成 `tests/sessions/session_memory_summary_diff_report.json`。 +- [ ] **Step 1:** 写 `test_replay_consistency_lightweight`:跑全部 10 case × `enabled_backends()`(轻量=in_memory+sqlite),断言正常 case 全 `match`、`false_positive_rate==0.0`,生成 `tests/sessions/artifacts/session_memory_summary_diff_report.json`。 - [ ] **Step 2:** 写 `test_injection_detection_100pct`:`test_replay_injections.py` 里 10 case 各注入一种 → `detected == [True]*10`。 - [ ] **Step 3:** 写 `test_summary_three_classes_100pct`:loss/overwrite/affiliation 各注入 → 全检出。 - [ ] **Step 4:** 跑 `PYTHONUTF8=1 pytest tests/sessions/test_replay_*.py tests/sessions/test_allowed_diff_governance.py tests/sessions/test_summary_checks.py -v`,确认全绿、轻量 ≤30s、报告产物生成且可定位。 diff --git a/tests/sessions/replay/README.md b/tests/sessions/replay/README.md index f4b4b2b54..1690ec374 100644 --- a/tests/sessions/replay/README.md +++ b/tests/sessions/replay/README.md @@ -58,9 +58,9 @@ TRPC_REPLAY_REDIS_URL=redis://localhost:6379/0 PYTHONUTF8=1 pytest tests/session - `python-magic` 在 Windows 上会致 SDK 导入崩溃,需改用 `python-magic-bin`(venv 内替换)。 ### 7. 报告产物位置 -- 运行 `test_replay_consistency.py` 会生成 / 覆盖 `tests/sessions/session_memory_summary_diff_report.json` +- 运行 `test_replay_consistency.py` 会生成 / 覆盖 `tests/sessions/artifacts/session_memory_summary_diff_report.json` (schema_version=3,每条 diff 内联 `session_id` / `event_index` / `summary_id` / `field_path` + 双后端值, - 不嵌全量 snapshot)。该文件作为测试基线产物**已纳入版本管理**,review/排障时可直接查看。 + 不嵌全量 snapshot)。该文件是可重复生成的运行时产物,目录已加入 `.gitignore`,需要时通过测试或 CLI 重新生成。 ## 目录结构 @@ -83,5 +83,5 @@ tests/sessions/ ├── test_replay_consistency.py # 主 E2E(正向:一致性 + FPR) ├── test_replay_injections.py # 注入检出(负向:错误场景) ├── test_replay_unit.py # 模块单测 -└── session_memory_summary_diff_report.json # 报告产物(运行时生成) +└── artifacts/session_memory_summary_diff_report.json # 报告产物(运行时生成,Git 忽略) ``` diff --git a/tests/sessions/replay/backends.py b/tests/sessions/replay/backends.py index 5c638a0fe..b178eb557 100644 --- a/tests/sessions/replay/backends.py +++ b/tests/sessions/replay/backends.py @@ -28,6 +28,7 @@ from trpc_agent_sdk.sessions._summarizer_manager import SummarizerSessionManager from .harness import ReplayBackend +from .redis_support import redis_unavailable_reason from .report import BackendStatus @@ -75,7 +76,10 @@ def sqlite_backend(db_url: str = "sqlite:///:memory:") -> ReplayBackend: def redis_backend(url: str) -> ReplayBackend: - svc = RedisSessionService(db_url=url, summarizer_manager=_manager(), session_config=_session_config(), is_async=True) + svc = RedisSessionService(db_url=url, + summarizer_manager=_manager(), + session_config=_session_config(), + is_async=True) mem = RedisMemoryService(db_url=url, enabled=True, is_async=True) return ReplayBackend("redis", svc, mem) @@ -98,11 +102,19 @@ def enabled_backends(tmp_path: Optional[str] = None, ) -> tuple[list[ReplayBacke redis_url = os.environ.get("TRPC_REPLAY_REDIS_URL") if redis_url: - try: - backends.append(redis_backend(redis_url)) - statuses.append(BackendStatus(name="redis", status="match")) - except Exception as exc: # noqa: BLE001 - statuses.append(BackendStatus(name="redis", status="skipped", reason=str(exc))) + # Redis clients connect lazily, so constructing the backend cannot + # prove the configured endpoint is reachable. Probe it explicitly + # before adding Redis to the replay candidates. + # Redis 客户端采用延迟连接;构造成功不代表服务可达,因此先执行 PING。 + unavailable_reason = redis_unavailable_reason(redis_url) + if unavailable_reason: + statuses.append(BackendStatus(name="redis", status="skipped", reason=unavailable_reason)) + else: + try: + backends.append(redis_backend(redis_url)) + statuses.append(BackendStatus(name="redis", status="match")) + except Exception as exc: # noqa: BLE001 + statuses.append(BackendStatus(name="redis", status="skipped", reason=str(exc))) else: statuses.append(BackendStatus(name="redis", status="skipped", reason="TRPC_REPLAY_REDIS_URL unset")) diff --git a/tests/sessions/replay/normalizer.py b/tests/sessions/replay/normalizer.py index 7b2f9ec39..2e8e659c2 100644 --- a/tests/sessions/replay/normalizer.py +++ b/tests/sessions/replay/normalizer.py @@ -14,6 +14,8 @@ import json from typing import Any +from trpc_agent_sdk.sessions import SESSION_SUMMARY_METADATA_KEY + from .harness import NORMALIZED from .harness import ReplaySnapshot @@ -27,6 +29,27 @@ def normalize_event(event: dict[str, Any]) -> dict[str, Any]: for key in VOLATILE_KEYS: if key in out: out[key] = NORMALIZED + + # A structured Summary event repeats its generated ID and real update + # clock in metadata. Normalize only those volatile representations while + # keeping ownership, text, version and replacement presence strict. + # 结构化 Summary Event 会在元数据中重复自动 ID 和真实更新时间;这里只 + # 归一化这些非业务表示,归属、正文、版本及覆盖关系是否存在仍严格比较。 + custom_metadata = out.get("custom_metadata") + if isinstance(custom_metadata, dict): + custom_metadata = dict(custom_metadata) + summary_metadata = custom_metadata.get(SESSION_SUMMARY_METADATA_KEY) + if isinstance(summary_metadata, dict): + summary_metadata = dict(summary_metadata) + if isinstance(summary_metadata.get("summary_id"), str): + summary_metadata["summary_id"] = NORMALIZED + if isinstance(summary_metadata.get("summary_timestamp"), (int, float)): + summary_metadata["summary_timestamp"] = NORMALIZED + if isinstance(summary_metadata.get("replaces_summary_id"), str): + summary_metadata["replaces_summary_id"] = NORMALIZED + custom_metadata[SESSION_SUMMARY_METADATA_KEY] = summary_metadata + out["custom_metadata"] = custom_metadata + # long_running_tool_ids: InMemory=None vs SQL=set() 的良性序列化差异,统一空值; # 一方有值一方空的真丢失仍会被检出。 lr = out.get("long_running_tool_ids") diff --git a/tests/sessions/replay/redis_support.py b/tests/sessions/replay/redis_support.py new file mode 100644 index 000000000..98c69c4a9 --- /dev/null +++ b/tests/sessions/replay/redis_support.py @@ -0,0 +1,64 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Redis availability helpers shared by replay integration tests. + +回放集成测试共用的 Redis 可用性检查工具。 +""" + +from __future__ import annotations + +import os +from typing import Optional + +import pytest +import redis + + +def redis_unavailable_reason(redis_url: str) -> Optional[str]: + """Return an actionable reason when Redis cannot answer ``PING``. + + 当 Redis 无法响应 ``PING`` 时返回可操作的原因;可用时返回 ``None``。 + The URL is deliberately omitted from the message because it may contain + credentials. + + 错误消息不回显 URL,避免泄露其中可能包含的认证信息。 + """ + client = None + try: + # Keep the probe short so an invalid opt-in URL does not stall the + # lightweight test suite. + # 使用短超时,避免错误的可选 URL 阻塞轻量测试流程。 + client = redis.Redis.from_url( + redis_url, + socket_connect_timeout=0.5, + socket_timeout=0.5, + ) + if client.ping(): + return None + return "configured Redis did not return PONG" + except Exception as exc: # noqa: BLE001 - availability probe must report every client failure + return f"configured Redis is unavailable: {type(exc).__name__}: {exc}" + finally: + if client is not None: + try: + client.close() + except Exception: # noqa: BLE001 - cleanup must not hide the probe result + pass + + +def require_replay_redis() -> str: + """Return the configured reachable Redis URL or skip the integration test. + + 返回已配置且可连接的 Redis URL;否则跳过可选集成测试。 + """ + redis_url = os.getenv("TRPC_REPLAY_REDIS_URL") + if not redis_url: + pytest.skip("TRPC_REPLAY_REDIS_URL is not configured") + + reason = redis_unavailable_reason(redis_url) + if reason: + pytest.skip(f"{reason}; start Redis or unset TRPC_REPLAY_REDIS_URL") + return redis_url diff --git a/tests/sessions/replay/report.py b/tests/sessions/replay/report.py index af6d1d9ea..e8ba3b022 100644 --- a/tests/sessions/replay/report.py +++ b/tests/sessions/replay/report.py @@ -13,6 +13,7 @@ from __future__ import annotations import json +from pathlib import Path from typing import Any from typing import Literal @@ -140,6 +141,10 @@ def build_diff_report( def write_report(report: dict[str, Any], path: str) -> None: - """把报告写入 JSON 文件(默认 tests/sessions/session_memory_summary_diff_report.json,路径由调用方传入)。""" - with open(path, "w", encoding="utf-8") as f: + """把报告写入 JSON 文件(默认 tests/sessions/artifacts/session_memory_summary_diff_report.json,路径由调用方传入)。""" + # The report directory is intentionally generated and may not exist in a clean checkout. + # 报告目录属于运行时产物,干净检出中可能不存在,因此写入前主动创建。 + output_path = Path(path) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2) diff --git a/tests/sessions/replay/session-memory-replay-consistency-design.md b/tests/sessions/replay/session-memory-replay-consistency-design.md index e45e05053..b0135afe6 100644 --- a/tests/sessions/replay/session-memory-replay-consistency-design.md +++ b/tests/sessions/replay/session-memory-replay-consistency-design.md @@ -103,7 +103,7 @@ tests/sessions/test_replay_consistency.py # 主 E2E tests/sessions/test_replay_injections.py # 快照层 + 端到端注入检出 tests/sessions/test_allowed_diff_governance.py # 精确匹配 + 覆盖率上限 tests/sessions/test_summary_checks.py # 三类 summary 故障 -tests/sessions/session_memory_summary_diff_report.json # 报告产物(运行时生成) +tests/sessions/artifacts/session_memory_summary_diff_report.json # 报告产物(运行时生成) ``` --- @@ -408,7 +408,7 @@ Redis/MySQL 不可用时 `pytest.skip`(满足 issue"不要求本地装真 Redis/ 1. `tests/sessions/test_replay_consistency.py` + `tests/sessions/replay/` harness 包 2. `tests/sessions/replay/replay_cases/*.jsonl`(10 条) -3. `tests/sessions/session_memory_summary_diff_report.json`(运行时生成) +3. `tests/sessions/artifacts/session_memory_summary_diff_report.json`(运行时生成) 4. 150–300 字设计说明(本文档 §10 + 测试包 `__init__.py` docstring) 5. 本设计文档 + 实施计划(均置于 `tests/sessions/replay/`,随测试代码同置) diff --git a/tests/sessions/replay_cases/standard_cases.jsonl b/tests/sessions/replay_cases/standard_cases.jsonl new file mode 100644 index 000000000..9dd3ea0d7 --- /dev/null +++ b/tests/sessions/replay_cases/standard_cases.jsonl @@ -0,0 +1,725 @@ +{ + "case_id": "single_turn_text", + "operations": [ + { + "op": "create_session" + }, + { + "op": "append_text", + "event_id": "single-e1", + "timestamp": 1001.0, + "author": "user", + "text": "Hello agent." + }, + { + "op": "append_text", + "event_id": "single-e2", + "timestamp": 1002.0, + "author": "assistant", + "text": "Hello user." + } + ], + "memory_queries": [], + "expect": { + "session_id": "single-turn-session", + "event_ids": [ + "single-e1", + "single-e2" + ], + "historical_event_ids": [], + "state": {}, + "summary": null + } +} + +{ + "case_id": "multi_turn_text", + "operations": [ + { + "op": "create_session" + }, + { + "op": "append_text", + "event_id": "multi-e1", + "timestamp": 1101.0, + "author": "user", + "text": "My name is Alice." + }, + { + "op": "append_text", + "event_id": "multi-e2", + "timestamp": 1102.0, + "author": "assistant", + "text": "Nice to meet you, Alice." + }, + { + "op": "append_text", + "event_id": "multi-e3", + "timestamp": 1103.0, + "author": "user", + "text": "What is my name?" + }, + { + "op": "append_text", + "event_id": "multi-e4", + "timestamp": 1104.0, + "author": "assistant", + "text": "Your name is Alice." + }, + { + "op": "append_text", + "event_id": "multi-e5", + "timestamp": 1105.0, + "author": "user", + "text": "Remember that for later." + }, + { + "op": "append_text", + "event_id": "multi-e6", + "timestamp": 1106.0, + "author": "assistant", + "text": "I will keep it in this session." + } + ], + "memory_queries": [], + "expect": { + "session_id": "multi-turn-session", + "event_ids": [ + "multi-e1", + "multi-e2", + "multi-e3", + "multi-e4", + "multi-e5", + "multi-e6" + ], + "historical_event_ids": [], + "state": {}, + "summary": null + } +} + +{ + "case_id": "tool_call_response", + "operations": [ + { + "op": "create_session" + }, + { + "op": "append_text", + "event_id": "tool-e1", + "timestamp": 1201.0, + "author": "user", + "text": "What is the weather in Beijing?" + }, + { + "op": "function_call", + "event_id": "tool-e2", + "timestamp": 1202.0, + "author": "assistant", + "call_id": "weather-call-1", + "name": "get_weather", + "args": { + "city": "Beijing" + } + }, + { + "op": "function_response", + "event_id": "tool-e3", + "timestamp": 1203.0, + "author": "assistant", + "call_id": "weather-call-1", + "name": "get_weather", + "response": { + "condition": "sunny", + "temperature": 30 + } + }, + { + "op": "append_text", + "event_id": "tool-e4", + "timestamp": 1204.0, + "author": "assistant", + "text": "Beijing is sunny and 30 degrees." + } + ], + "memory_queries": [], + "expect": { + "session_id": "tool-session", + "event_ids": [ + "tool-e1", + "tool-e2", + "tool-e3", + "tool-e4" + ], + "historical_event_ids": [], + "state": {}, + "summary": null + } +} + +{ + "case_id": "session_state_overwrite", + "operations": [ + { + "op": "create_session", + "state": { + "counter": 0, + "mode": "new" + } + }, + { + "op": "state_update", + "event_id": "state-e1", + "timestamp": 1301.0, + "author": "assistant", + "state_delta": { + "counter": 1, + "mode": "draft" + } + }, + { + "op": "state_update", + "event_id": "state-e2", + "timestamp": 1302.0, + "author": "assistant", + "state_delta": { + "counter": 2, + "mode": "final" + } + } + ], + "memory_queries": [], + "expect": { + "session_id": "state-overwrite-session", + "event_ids": [ + "state-e1", + "state-e2" + ], + "historical_event_ids": [], + "state": { + "counter": 2, + "mode": "final" + }, + "summary": null + } +} + +{ + "case_id": "scoped_state_update", + "operations": [ + { + "op": "create_session", + "state": { + "app:region": "cn", + "user:language": "zh", + "session_value": "initial", + "temp:seed": "discard" + } + }, + { + "op": "state_update", + "event_id": "scoped-e1", + "timestamp": 1401.0, + "author": "assistant", + "state_delta": { + "app:region": "global", + "user:language": "en", + "session_value": "updated", + "temp:request": "secret" + } + } + ], + "memory_queries": [], + "expect": { + "session_id": "scoped-state-session", + "event_ids": [ + "scoped-e1" + ], + "historical_event_ids": [], + "state": { + "app:region": "global", + "user:language": "en", + "session_value": "updated" + }, + "summary": null + } +} + +{ + "case_id": "memory_store_search", + "operations": [ + { + "op": "create_session" + }, + { + "op": "append_text", + "event_id": "memory-e1", + "timestamp": 1501.0, + "author": "user", + "text": "My favorite color is blue." + }, + { + "op": "append_text", + "event_id": "memory-e2", + "timestamp": 1502.0, + "author": "user", + "text": "I live in Paris." + }, + { + "op": "append_text", + "event_id": "memory-e3", + "timestamp": 1503.0, + "author": "assistant", + "text": "I will remember those preferences." + }, + { + "op": "store_memory" + } + ], + "memory_queries": [ + "blue", + "Paris" + ], + "expect": { + "session_id": "memory-session", + "event_ids": [ + "memory-e1", + "memory-e2", + "memory-e3" + ], + "historical_event_ids": [], + "state": {}, + "summary": null, + "memory": { + "blue": [ + "My favorite color is blue." + ], + "Paris": [ + "I live in Paris." + ] + } + } +} + +{ + "case_id": "summary_create", + "operations": [ + { + "op": "create_session" + }, + { + "op": "append_text", + "event_id": "summary-create-e1", + "timestamp": 1601.0, + "author": "user", + "text": "We need a project plan." + }, + { + "op": "append_text", + "event_id": "summary-create-e2", + "timestamp": 1602.0, + "author": "assistant", + "text": "Start with requirements." + }, + { + "op": "append_text", + "event_id": "summary-create-e3", + "timestamp": 1603.0, + "author": "user", + "text": "The deadline is Friday." + }, + { + "op": "append_text", + "event_id": "summary-create-e4", + "timestamp": 1604.0, + "author": "assistant", + "text": "I added the deadline." + }, + { + "op": "append_text", + "event_id": "summary-create-e5", + "timestamp": 1605.0, + "author": "user", + "text": "What should we do next?" + }, + { + "op": "append_text", + "event_id": "summary-create-e6", + "timestamp": 1606.0, + "author": "assistant", + "text": "Assign owners to tasks." + }, + { + "op": "create_summary", + "summary_text": "The user planned a project with a Friday deadline." + }, + { + "op": "clear_summary_cache" + }, + { + "op": "reload_session" + } + ], + "memory_queries": [], + "expect": { + "session_id": "summary-create-session", + "event_ids": [ + "summary:v1", + "summary-create-e5", + "summary-create-e6" + ], + "historical_event_ids": [ + "summary-create-e1", + "summary-create-e2", + "summary-create-e3", + "summary-create-e4" + ], + "state": {}, + "summary": { + "summary_id": "summary:v1", + "session_id": "summary-create-session", + "summary_text": "The user planned a project with a Friday deadline.", + "version": 1, + "replaces_summary_id": null, + "original_event_count": 6, + "compressed_event_count": 3, + "recovered_text": "The user planned a project with a Friday deadline." + } + } +} + +{ + "case_id": "summary_update_replace", + "operations": [ + { + "op": "create_session" + }, + { + "op": "append_text", + "event_id": "summary-update-e1", + "timestamp": 1701.0, + "author": "user", + "text": "Plan the launch." + }, + { + "op": "append_text", + "event_id": "summary-update-e2", + "timestamp": 1702.0, + "author": "assistant", + "text": "We need a launch checklist." + }, + { + "op": "append_text", + "event_id": "summary-update-e3", + "timestamp": 1703.0, + "author": "user", + "text": "Include documentation." + }, + { + "op": "append_text", + "event_id": "summary-update-e4", + "timestamp": 1704.0, + "author": "assistant", + "text": "Documentation is included." + }, + { + "op": "append_text", + "event_id": "summary-update-e5", + "timestamp": 1705.0, + "author": "user", + "text": "Keep the first recent item." + }, + { + "op": "append_text", + "event_id": "summary-update-e6", + "timestamp": 1706.0, + "author": "assistant", + "text": "Keep the second recent item." + }, + { + "op": "create_summary", + "summary_text": "The launch plan includes a checklist and documentation." + }, + { + "op": "append_text", + "event_id": "summary-update-e7", + "timestamp": 1707.0, + "author": "user", + "text": "Add a security review." + }, + { + "op": "append_text", + "event_id": "summary-update-e8", + "timestamp": 1708.0, + "author": "assistant", + "text": "Security review added." + }, + { + "op": "append_text", + "event_id": "summary-update-e9", + "timestamp": 1709.0, + "author": "user", + "text": "Who owns the release?" + }, + { + "op": "append_text", + "event_id": "summary-update-e10", + "timestamp": 1710.0, + "author": "assistant", + "text": "The release manager owns it." + }, + { + "op": "create_summary", + "summary_text": "The launch includes documentation, security review, and release ownership." + }, + { + "op": "clear_summary_cache" + }, + { + "op": "reload_session" + } + ], + "memory_queries": [ + "checklist" + ], + "expect": { + "session_id": "summary-update-session", + "event_ids": [ + "summary:v2", + "summary-update-e9", + "summary-update-e10" + ], + "historical_event_ids": [ + "summary-update-e1", + "summary-update-e2", + "summary-update-e3", + "summary-update-e4", + "summary:v1", + "summary-update-e5", + "summary-update-e6", + "summary-update-e7", + "summary-update-e8" + ], + "state": {}, + "summary": { + "summary_id": "summary:v2", + "session_id": "summary-update-session", + "summary_text": "The launch includes documentation, security review, and release ownership.", + "version": 2, + "replaces_summary_id": "summary:v1", + "original_event_count": 7, + "compressed_event_count": 3, + "recovered_text": "The launch includes documentation, security review, and release ownership." + }, + "memory": { + "checklist": [] + } + } +} + +{ + "case_id": "summary_truncate_continue", + "operations": [ + { + "op": "create_session" + }, + { + "op": "append_text", + "event_id": "truncate-e1", + "timestamp": 1801.0, + "author": "user", + "text": "Old question one." + }, + { + "op": "append_text", + "event_id": "truncate-e2", + "timestamp": 1802.0, + "author": "assistant", + "text": "Old answer one." + }, + { + "op": "append_text", + "event_id": "truncate-e3", + "timestamp": 1803.0, + "author": "user", + "text": "Old question two." + }, + { + "op": "append_text", + "event_id": "truncate-e4", + "timestamp": 1804.0, + "author": "assistant", + "text": "Old answer two." + }, + { + "op": "append_text", + "event_id": "truncate-e5", + "timestamp": 1805.0, + "author": "user", + "text": "Recent question." + }, + { + "op": "append_text", + "event_id": "truncate-e6", + "timestamp": 1806.0, + "author": "assistant", + "text": "Recent answer." + }, + { + "op": "create_summary", + "summary_text": "The earlier conversation contained two resolved questions." + }, + { + "op": "reload_session" + }, + { + "op": "append_text", + "event_id": "truncate-e7", + "timestamp": 1807.0, + "author": "user", + "text": "Continuation question." + }, + { + "op": "append_text", + "event_id": "truncate-e8", + "timestamp": 1808.0, + "author": "assistant", + "text": "Continuation answer." + }, + { + "op": "store_memory" + }, + { + "op": "clear_summary_cache" + }, + { + "op": "reload_session" + } + ], + "memory_queries": [ + "Continuation" + ], + "expect": { + "session_id": "summary-truncate-session", + "event_ids": [ + "summary:v1", + "truncate-e5", + "truncate-e6", + "truncate-e7", + "truncate-e8" + ], + "historical_event_ids": [ + "truncate-e1", + "truncate-e2", + "truncate-e3", + "truncate-e4" + ], + "state": {}, + "summary": { + "summary_id": "summary:v1", + "session_id": "summary-truncate-session", + "summary_text": "The earlier conversation contained two resolved questions.", + "version": 1, + "replaces_summary_id": null, + "recovered_text": "The earlier conversation contained two resolved questions." + }, + "memory": { + "Continuation": [ + "Continuation answer.", + "Continuation question." + ] + } + } +} + +{ + "case_id": "failure_retry_duplicate", + "operations": [ + { + "op": "create_session" + }, + { + "op": "append_text", + "event_id": "failure-e1", + "timestamp": 1901.0, + "author": "user", + "text": "Prepare the deployment." + }, + { + "op": "append_text", + "event_id": "failure-e2", + "timestamp": 1902.0, + "author": "assistant", + "text": "Deployment preparation started." + }, + { + "op": "append_text", + "event_id": "failure-e3", + "timestamp": 1903.0, + "author": "user", + "text": "Run the checks." + }, + { + "op": "append_text", + "event_id": "failure-e4", + "timestamp": 1904.0, + "author": "assistant", + "text": "Checks are ready." + }, + { + "op": "create_summary", + "summary_text": "Deployment preparation and checks are ready." + }, + { + "op": "reload_session" + }, + { + "op": "append_with_failure_retry", + "event_id": "failure-e5", + "timestamp": 1905.0, + "author": "assistant", + "text": "Deployment completed safely.", + "state_delta": { + "deployment_status": "done" + } + }, + { + "op": "clear_summary_cache" + }, + { + "op": "reload_session" + } + ], + "memory_queries": [ + "completed" + ], + "expect": { + "session_id": "failure-retry-session", + "event_ids": [ + "summary:v1", + "failure-e3", + "failure-e4", + "failure-e5" + ], + "historical_event_ids": [ + "failure-e1", + "failure-e2" + ], + "state": { + "deployment_status": "done" + }, + "summary": { + "summary_id": "summary:v1", + "session_id": "failure-retry-session", + "summary_text": "Deployment preparation and checks are ready.", + "version": 1, + "replaces_summary_id": null, + "recovered_text": "Deployment preparation and checks are ready." + }, + "memory": { + "completed": [ + "Deployment completed safely." + ] + } + } +} diff --git a/tests/sessions/replay_harness.py b/tests/sessions/replay_harness.py new file mode 100644 index 000000000..76096c502 --- /dev/null +++ b/tests/sessions/replay_harness.py @@ -0,0 +1,1205 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Reusable Session / Memory / Summary replay consistency harness. + +可复用的 Session / Memory / Summary 跨后端回放一致性测试框架。 +""" + +from __future__ import annotations + +import argparse +import asyncio +import copy +import fnmatch +import json +import os +import re +import tempfile +import time +import unicodedata +import uuid +from dataclasses import asdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from typing import AsyncGenerator +from typing import Iterable +from typing import Optional + +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.memory import InMemoryMemoryService +from trpc_agent_sdk.memory import RedisMemoryService +from trpc_agent_sdk.memory import SqlMemoryService +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.sessions import RedisSessionService +from trpc_agent_sdk.sessions import SESSION_SUMMARY_METADATA_KEY +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.sessions import SessionServiceConfig +from trpc_agent_sdk.sessions import SessionSummarizer +from trpc_agent_sdk.sessions import SqlSessionService +from trpc_agent_sdk.sessions import SummarizerSessionManager +from trpc_agent_sdk.sessions import session_summary_from_event +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse +from trpc_agent_sdk.types import Part + +DEFAULT_CASES_PATH = Path(__file__).with_name("replay_cases") / "standard_cases.jsonl" +# Keep generated reports with test artifacts instead of rewriting the repo root. +# 将生成报告放在测试产物目录,避免每次 CLI 运行都改写仓库根目录。 +DEFAULT_REPORT_PATH = Path(__file__).parent / "artifacts" / "session_memory_summary_diff_report.json" + + +@dataclass(frozen=True) +class ReplayCase: + """Describe one backend-independent replay trace. + + 描述一条与具体存储后端无关的标准回放轨迹。 + """ + + case_id: str + operations: list[dict[str, Any]] + expect: dict[str, Any] + memory_queries: list[str] + + +@dataclass +class BackendBundle: + """Group all services required to replay against one backend. + + 组合在单个后端执行回放所需的 Session、Memory 与 Summary 服务。 + """ + + name: str + session_service: Any + memory_service: Any + summarizer_manager: SummarizerSessionManager + summary_model: "DeterministicSummaryModel" + + async def close(self) -> None: + """Close Session and Memory resources owned by this bundle. + + 关闭当前后端组合持有的 Session 与 Memory 资源。 + """ + await self.session_service.close() + await self.memory_service.close() + + +@dataclass(frozen=True) +class AllowedDiff: + """Declare one narrowly scoped, explainable backend difference. + + 声明一项范围明确且有原因说明的后端允许差异。 + """ + + component: str + path: str + backends: tuple[str, ...] + strategy: str + reason: str + + +@dataclass +class DiffEntry: + """Record one snapshot difference with a precise diagnostic location. + + 记录一项快照差异,并携带可精确定位问题的诊断信息。 + """ + + case_id: str + session_id: str + component: str + field_path: str + reference_backend: str + reference_value: Any + candidate_backend: str + candidate_value: Any + event_index: Optional[int] = None + summary_id: Optional[str] = None + allowed: bool = False + reason: Optional[str] = None + + +# Allowed differences must name an exact field and strategy; business-value +# mismatches are never globally ignored. +# 允许差异必须明确字段与策略,绝不能全局忽略业务值的不一致。 +ALLOWED_DIFFS = [ + AllowedDiff( + component="session", + path="/last_update_time", + backends=("inmemory", "sqlite", "sql", "redis"), + strategy="omit_backend_clock", + reason="Session update time is assigned by either the process clock or the storage engine clock.", + ), + AllowedDiff( + component="memory", + path="/memory/$result_order", + backends=("inmemory", "sqlite", "sql", "redis"), + strategy="stable_multiset_sort", + reason="MemoryServiceABC does not define result ordering; multiplicity and entry values remain strict.", + ), + AllowedDiff( + component="events", + path="/events/*/long_running_tool_ids", + backends=("inmemory", "sqlite", "sql", "redis"), + strategy="none_equals_empty_set", + reason="SQL restores an absent long-running tool ID set as an empty set.", + ), +] + +# Keep normalization rules visible in the report so consumers can distinguish +# normalized non-business fields from fields compared strictly. +# 将归一化规则写入报告,便于区分非业务字段与严格比较字段。 +NORMALIZATION_RULES = [ + { + "path": "/events/*/timestamp", + "strategy": "fixed_fixture_time_or_summary_version_token", + "reason": ( + "Fixture event times remain strict; generated summary times are compared by version and monotonicity." + ), + }, + { + "path": "/summary/summary_text", + "strategy": "unicode_nfc_and_whitespace", + "reason": "Summary presentation whitespace is non-semantic; ownership and version metadata remain strict.", + }, + { + "path": "/memory", + "strategy": "stable_multiset_sort", + "reason": "Search ordering is not part of MemoryServiceABC, but duplicate entries are retained and detected.", + }, + { + "path": "/**", + "strategy": "recursive_key_sort", + "reason": "Serialized dictionary insertion order is not business data.", + }, +] + + +class DeterministicSummaryModel(LLMModel): + """Return operation-controlled summaries without using the network. + + 返回由回放操作预设的摘要,不发起任何网络模型请求。 + """ + + def __init__(self) -> None: + """Initialize the fixed model identity and an empty next response. + + 初始化固定模型标识及空的下一次摘要响应。 + """ + super().__init__(model_name="replay-summary-model") + self._next_summary = "" + + @classmethod + def supported_models(cls) -> list[str]: + """Return the single synthetic model supported by the harness. + + 返回回放框架唯一支持的合成模型名称。 + """ + return ["replay-summary-model"] + + def set_next_summary(self, summary_text: str) -> None: + """Set the exact summary returned by the next generation call. + + 设置下一次生成调用必须返回的确定性摘要文本。 + """ + self._next_summary = summary_text + + async def _generate_async_impl( + self, + request: LlmRequest, + stream: bool = False, + ctx: Any = None, + ) -> AsyncGenerator[LlmResponse, None]: + """Yield one deterministic model response for Summary generation. + + 为 Summary 生成流程产出一个确定性的模型响应。 + """ + del request, stream, ctx + yield LlmResponse( + content=Content( + role="model", + parts=[Part.from_text(text=self._next_summary)], + ) + ) + + +def load_replay_cases(path: Path = DEFAULT_CASES_PATH) -> list[ReplayCase]: + """Load consecutive JSON case objects from a readable JSONL-style file. + + 从类 JSONL 文件加载连续 JSON 对象;兼容单行或跨行对象、空行及整行 + ``#`` 注释,并在格式错误时报告大致行号。 + """ + cases: list[ReplayCase] = [] + raw_lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + + # Replace full-line comments with blank lines instead of deleting them, so + # decoder character offsets still map to useful source line numbers. + # 用空行替换整行注释而非直接删除,使解析偏移仍可映射到有用的源文件行号。 + source = "".join("\n" if line.lstrip().startswith("#") else line for line in raw_lines) + decoder = json.JSONDecoder() + cursor = 0 + source_length = len(source) + + while cursor < source_length: + # raw_decode does not skip leading whitespace, so advance to the next + # object explicitly; this also supports blank lines between cases. + # raw_decode 不跳过前导空白,因此显式移动到下一个对象,并兼容用例间空行。 + while cursor < source_length and source[cursor].isspace(): + cursor += 1 + if cursor >= source_length: + break + + object_line = source.count("\n", 0, cursor) + 1 + try: + data, cursor = decoder.raw_decode(source, cursor) + except json.JSONDecodeError as exc: + error_line = source.count("\n", 0, exc.pos) + 1 + raise ValueError(f"Invalid replay case near {path}:{error_line}: {exc.msg}") from exc + + if not isinstance(data, dict): + raise ValueError(f"Invalid replay case near {path}:{object_line}: expected a JSON object") + try: + cases.append( + ReplayCase( + case_id=data["case_id"], + operations=data["operations"], + expect=data.get("expect", {}), + memory_queries=data.get("memory_queries", []), + ) + ) + except (KeyError, TypeError) as exc: + raise ValueError(f"Invalid replay case fields near {path}:{object_line}") from exc + return cases + + +def _session_config() -> SessionServiceConfig: + """Build identical Session retention settings for every backend. + + 为所有后端构建完全一致的 Session 事件保留配置。 + """ + config = SessionServiceConfig( + max_events=0, + num_recent_events=0, + store_historical_events=True, + ) + config.clean_ttl_config() + return config + + +def _memory_config() -> MemoryServiceConfig: + """Build an enabled Memory configuration without TTL side effects. + + 构建已启用且不受 TTL 影响的 Memory 配置。 + """ + config = MemoryServiceConfig(enabled=True) + config.clean_ttl_config() + return config + + +def _summary_components() -> tuple[DeterministicSummaryModel, SummarizerSessionManager]: + """Create deterministic Summary components shared by one backend bundle. + + 创建一套由单个后端组合使用的确定性 Summary 组件。 + """ + model = DeterministicSummaryModel() + summarizer = SessionSummarizer( + model=model, + check_summarizer_functions=[lambda _session: False], + keep_recent_count=2, + ) + manager = SummarizerSessionManager( + model=model, + summarizer=summarizer, + auto_summarize=False, + ) + return model, manager + + +async def create_backend(name: str, work_dir: Path) -> BackendBundle: + """Create one uniformly configured replay backend and its resources. + + 创建一套配置统一的回放后端及其 Session、Memory、Summary 资源。 + """ + model, manager = _summary_components() + session_config = _session_config() + memory_config = _memory_config() + + if name == "inmemory": + session_service = InMemorySessionService( + summarizer_manager=manager, + session_config=session_config, + ) + memory_service = InMemoryMemoryService(memory_service_config=memory_config) + elif name == "sqlite": + db_path = work_dir / "replay.sqlite3" + db_url = f"sqlite:///{db_path}" + session_service = SqlSessionService( + db_url=db_url, + summarizer_manager=manager, + session_config=session_config, + is_async=False, + ) + memory_service = SqlMemoryService( + db_url=db_url, + memory_service_config=memory_config, + is_async=False, + ) + await session_service._sql_storage.create_sql_engine() # pylint: disable=protected-access + await memory_service._sql_storage.create_sql_engine() # pylint: disable=protected-access + elif name == "sql": + db_url = os.environ["TRPC_REPLAY_SQL_URL"] + session_service = SqlSessionService( + db_url=db_url, + summarizer_manager=manager, + session_config=session_config, + is_async=False, + ) + memory_service = SqlMemoryService( + db_url=db_url, + memory_service_config=memory_config, + is_async=False, + ) + await session_service._sql_storage.create_sql_engine() # pylint: disable=protected-access + await memory_service._sql_storage.create_sql_engine() # pylint: disable=protected-access + elif name == "redis": + redis_url = os.environ["TRPC_REPLAY_REDIS_URL"] + session_service = RedisSessionService( + db_url=redis_url, + summarizer_manager=manager, + session_config=session_config, + is_async=False, + ) + memory_service = RedisMemoryService( + db_url=redis_url, + memory_service_config=memory_config, + is_async=False, + ) + else: + raise ValueError(f"Unsupported replay backend: {name}") + + return BackendBundle( + name=name, + session_service=session_service, + memory_service=memory_service, + summarizer_manager=manager, + summary_model=model, + ) + + +def selected_backend_names() -> list[str]: + """Resolve default and opt-in integration backends from environment variables. + + 根据环境变量解析默认后端及需显式启用的集成后端。 + """ + configured = os.getenv("TRPC_REPLAY_BACKENDS") + if configured: + names = [name.strip().lower() for name in configured.split(",") if name.strip()] + else: + names = ["inmemory", "sqlite"] + + available: list[str] = [] + # Real Redis/SQL services are opt-in: silently omit them when their URL is + # unavailable so lightweight local and CI runs remain self-contained. + # 真实 Redis/SQL 服务采用显式启用策略;未提供 URL 时跳过,以保持轻量运行自包含。 + for name in names: + if name == "redis" and not os.getenv("TRPC_REPLAY_REDIS_URL"): + continue + if name == "sql" and not os.getenv("TRPC_REPLAY_SQL_URL"): + continue + available.append(name) + if not available: + raise ValueError("No replay backends are enabled") + return available + + +def _content_for_operation(operation: dict[str, Any]) -> Optional[Content]: + """Translate a replay operation into the corresponding event Content. + + 将回放操作转换为对应的文本、函数调用或函数响应 Content。 + """ + op_name = operation["op"] + if op_name in {"append_text", "append_with_failure_retry"}: + text = operation.get("text") + if text is None: + return None + role = operation.get("role") or ("user" if operation.get("author") == "user" else "model") + return Content(role=role, parts=[Part.from_text(text=text)]) + if op_name == "function_call": + return Content( + role="model", + parts=[ + Part( + function_call=FunctionCall( + id=operation.get("call_id"), + name=operation["name"], + args=operation.get("args", {}), + ) + ) + ], + ) + if op_name == "function_response": + return Content( + role="user", + parts=[ + Part( + function_response=FunctionResponse( + id=operation.get("call_id"), + name=operation["name"], + response=operation.get("response", {}), + ) + ) + ], + ) + if op_name == "state_update": + text = operation.get("text") + if text is not None: + return Content(role="model", parts=[Part.from_text(text=text)]) + return None + raise ValueError(f"Operation {op_name!r} does not create an event") + + +def _event_for_operation(operation: dict[str, Any]) -> Event: + """Build a deterministic Event while keeping its storage time current. + + 构造 ID 与逻辑顺序确定的 Event,同时使持久化时间接近当前时钟。 + """ + # JSON fixture timestamps are deterministic logical sequence numbers. Use a + # value close to the backend clock to avoid triggering SQL stale-write guards. + # JSON 用例时间戳只表示确定性逻辑顺序;使用接近后端时钟的值可避免误触 SQL 陈旧写保护。 + storage_timestamp = time.time() + float(operation["timestamp"]) / 1_000_000 + return Event( + id=operation["event_id"], + invocation_id=operation.get("invocation_id", f"inv-{operation['event_id']}"), + author=operation.get("author", "assistant"), + timestamp=storage_timestamp, + content=_content_for_operation(operation), + actions=EventActions(state_delta=operation.get("state_delta", {})), + partial=False, + ) + + +class ReplayExecutor: + """Interpret standard operations and replay them against one backend. + + 解释标准操作,并在单个后端上执行完整回放。 + """ + + def __init__(self, namespace: str) -> None: + """Store a run namespace that isolates generated application keys. + + 保存本次运行命名空间,用于隔离生成的应用存储键。 + """ + self._namespace = namespace + + async def execute(self, case: ReplayCase, backend: BackendBundle) -> dict[str, Any]: + """Execute one case, reread persisted data, and return its snapshot. + + 执行单个用例,从持久化后端重新读取数据,并返回规范化快照。 + """ + app_name = f"replay-{self._namespace}-{case.case_id}" + user_id = f"user-{case.case_id}" + session_id = case.expect.get("session_id", case.case_id) + session: Optional[Session] = None + event_by_id: dict[str, Event] = {} + observed_errors: list[dict[str, str]] = [] + + for operation in case.operations: + op_name = operation["op"] + if op_name == "create_session": + session = await backend.session_service.create_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + state=copy.deepcopy(operation.get("state", {})), + ) + continue + + if session is None: + raise ValueError(f"Case {case.case_id} must create a session before {op_name}") + + if op_name in { + "append_text", + "function_call", + "function_response", + "state_update", + }: + event = _event_for_operation(operation) + event_by_id[event.id] = event + await backend.session_service.append_event(session, event) + elif op_name == "create_summary": + backend.summary_model.set_next_summary(operation["summary_text"]) + await backend.summarizer_manager.create_session_summary(session, force=True) + if operation.get("store_memory", True): + await backend.memory_service.store_session(session) + elif op_name == "store_memory": + await backend.memory_service.store_session(session) + elif op_name == "reload_session": + reloaded = await backend.session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + if reloaded is None: + raise AssertionError(f"Session {session_id} disappeared during {case.case_id}") + session = reloaded + elif op_name == "clear_summary_cache": + backend.summarizer_manager._summarizer_cache.clear() # pylint: disable=protected-access + elif op_name == "repeat_event": + original = event_by_id[operation["event_id"]] + await backend.session_service.append_event(session, original.model_copy(deep=True)) + elif op_name == "append_with_failure_retry": + # Simulate failure after local Session mutation but before the + # storage write, then retry twice to verify backend idempotency. + # 模拟本地 Session 已变更但存储尚未写入时失败,再重试两次以验证后端幂等性。 + event = _event_for_operation(operation) + event_by_id[event.id] = event + try: + _, _, appended = ( + backend.session_service._append_event_to_session( # pylint: disable=protected-access + session, + event, + ) + ) + if not appended: + raise AssertionError("Fault injection event was unexpectedly a duplicate") + raise RuntimeError("simulated failure after local mutation and before storage") + except RuntimeError as exc: + observed_errors.append({"operation": op_name, "error": str(exc)}) + await backend.session_service.append_event(session, event.model_copy(deep=True)) + await backend.session_service.append_event(session, event.model_copy(deep=True)) + if operation.get("store_memory", True): + await backend.memory_service.store_session(session) + await backend.memory_service.store_session(session) + else: + raise ValueError(f"Unknown replay operation: {op_name}") + + if session is None: + raise AssertionError(f"Case {case.case_id} did not create a session") + + # Always reread the final Session from storage; comparing the caller's + # mutable object would hide serialization or persistence defects. + # 最终 Session 必须从存储重读;直接比较调用方对象会掩盖序列化或持久化缺陷。 + stored = await backend.session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + if stored is None: + raise AssertionError(f"Session {session_id} was not readable after replay") + + memory_results: dict[str, list[Any]] = {} + for query in case.memory_queries: + result = await backend.memory_service.search_memory(stored.save_key, query, limit=100) + memory_results[query] = result.memories + + # Force Summary recovery through persisted Event metadata rather than + # the manager's process-local cache. + # 清空进程内缓存,强制通过持久化 Event 元数据恢复 Summary。 + backend.summarizer_manager._summarizer_cache.clear() # pylint: disable=protected-access + recovered_summary = await backend.summarizer_manager.get_session_summary(stored) + return snapshot_from_backend( + backend=backend.name, + session=stored, + memory_results=memory_results, + recovered_summary_text=recovered_summary.summary_text if recovered_summary else None, + observed_errors=observed_errors, + ) + + +def _normalize_text(value: str) -> str: + """Normalize Unicode and whitespace without changing text semantics. + + 统一 Unicode 与空白表现形式,但不改变文本语义。 + """ + return " ".join(unicodedata.normalize("NFC", value).replace("\r\n", "\n").split()) + + +def _canonicalize(value: Any) -> Any: + """Recursively canonicalize serialization-only representation differences. + + 递归消除字典顺序、集合表示及浮点精度等纯序列化差异。 + """ + if isinstance(value, dict): + return {key: _canonicalize(value[key]) for key in sorted(value)} + if isinstance(value, list): + return [_canonicalize(item) for item in value] + if isinstance(value, set): + return sorted(_canonicalize(item) for item in value) + if isinstance(value, float): + return round(value, 6) + return value + + +def _summary_id_map(events: Iterable[Event]) -> dict[str, str]: + """Map generated Summary IDs to stable version-based logical IDs. + + 将自动生成的 Summary ID 映射为基于版本的稳定逻辑 ID。 + """ + mapping: dict[str, str] = {} + for event in events: + if event.is_summary_event(): + mapping[event.id] = f"summary:v{event.version or 1}" + return mapping + + +def _replace_summary_ids(value: Any, summary_ids: dict[str, str]) -> Any: + """Recursively replace Summary IDs, including replacement references. + + 递归替换 Summary ID,包括摘要覆盖关系中的引用。 + """ + if isinstance(value, dict): + return {key: _replace_summary_ids(item, summary_ids) for key, item in value.items()} + if isinstance(value, list): + return [_replace_summary_ids(item, summary_ids) for item in value] + if isinstance(value, str): + return summary_ids.get(value, value) + return value + + +def _event_snapshot(event: Event, summary_ids: dict[str, str]) -> dict[str, Any]: + """Normalize one Event while preserving all business-relevant fields. + + 规范化单个 Event,同时保留所有业务相关字段供严格比较。 + """ + data = event.model_dump(mode="json", exclude_none=True) + data["id"] = summary_ids.get(event.id, event.id) + data["long_running_tool_ids"] = sorted(event.long_running_tool_ids or []) + if event.is_summary_event(): + # Generated Summary IDs and clocks are backend-dependent, so compare + # stable versions while keeping text, ownership and metadata strict. + # Summary 自动 ID 与时钟依赖后端,因此按稳定版本比较,但文本、归属和元数据仍严格校验。 + version = event.version or 1 + data["timestamp"] = f"summary:v{version}:time" + content = data.get("content", {}) + for part in content.get("parts", []): + if isinstance(part.get("text"), str): + part["text"] = _normalize_text(part["text"]) + metadata = data.get("custom_metadata", {}).get(SESSION_SUMMARY_METADATA_KEY, {}) + if metadata: + metadata["summary_timestamp"] = f"summary:v{version}:time" + if isinstance(metadata.get("summary_text"), str): + metadata["summary_text"] = _normalize_text(metadata["summary_text"]) + else: + data["timestamp"] = f"event:{event.id}:time" + return _canonicalize(_replace_summary_ids(data, summary_ids)) + + +def _memory_entry_snapshot(entry: Any) -> dict[str, Any]: + """Normalize one Memory entry without discarding duplicates or content. + + 规范化单条 Memory,但不去重,也不忽略作者和内容。 + """ + content = entry.content.model_dump(mode="json", exclude_none=True) + for part in content.get("parts", []): + if isinstance(part.get("text"), str): + part["text"] = _normalize_text(part["text"]) + return _canonicalize( + { + "author": entry.author, + "content": content, + "timestamp": "" if entry.timestamp is not None else None, + } + ) + + +def snapshot_from_backend( + *, + backend: str, + session: Session, + memory_results: dict[str, list[Any]], + recovered_summary_text: Optional[str], + observed_errors: list[dict[str, str]], +) -> dict[str, Any]: + """Build a normalized snapshot suitable for strict cross-backend comparison. + + 构建适合跨后端严格比较的规范化 Session、Memory 与 Summary 快照。 + """ + all_events = [*session.historical_events, *session.events] + summary_ids = _summary_id_map(all_events) + summary_events = [event for event in session.events if event.is_summary_event()] + summary_event = max(summary_events, key=lambda item: (item.version, item.timestamp)) if summary_events else None + summary = session_summary_from_event(summary_event, session.id) if summary_event else None + decoded_summaries = [ + decoded + for event in all_events + if event.is_summary_event() + for decoded in [session_summary_from_event(event, session.id)] + if decoded is not None + ] + # Summary text receives only presentation normalization. Ownership, version, + # replacement chain, active count and update ordering remain strict fields. + # Summary 文本仅做展示层归一化;归属、版本、覆盖链、活跃数量和更新时间顺序均严格比较。 + ordered_summaries = sorted(decoded_summaries, key=lambda item: item.version) + update_time_monotonic = all( + current.summary_timestamp <= following.summary_timestamp + for current, following in zip(ordered_summaries, ordered_summaries[1:]) + ) + + summary_data: Optional[dict[str, Any]] + if summary is None: + summary_data = None + else: + summary_data = summary.model_dump(mode="json") + summary_data["summary_id"] = summary_ids.get(summary.summary_id, summary.summary_id) + if summary_data.get("replaces_summary_id"): + summary_data["replaces_summary_id"] = summary_ids.get( + summary_data["replaces_summary_id"], + summary_data["replaces_summary_id"], + ) + summary_data["summary_timestamp"] = f"summary:v{summary.version}:time" + metadata = summary_data.get("metadata") + if isinstance(metadata, dict) and "summary_timestamp" in metadata: + metadata["summary_timestamp"] = f"summary:v{summary.version}:time" + summary_data["summary_text"] = _normalize_text(summary.summary_text) + summary_data["recovered_text"] = ( + _normalize_text(recovered_summary_text) if recovered_summary_text is not None else None + ) + summary_data["active_summary_count"] = len(summary_events) + summary_data["update_time_monotonic"] = update_time_monotonic + summary_data = _replace_summary_ids(summary_data, summary_ids) + + normalized_memory: dict[str, list[dict[str, Any]]] = {} + # The Memory API does not guarantee search order. Stable multiset sorting + # removes only ordering noise and deliberately retains duplicate entries. + # Memory API 不保证搜索顺序;稳定的多重集排序仅消除顺序噪声,并刻意保留重复项。 + for query, entries in memory_results.items(): + normalized_entries = [_memory_entry_snapshot(entry) for entry in entries] + normalized_memory[query] = sorted( + normalized_entries, + key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True), + ) + + snapshot = { + "backend": backend, + "session_id": session.id, + "events": [_event_snapshot(event, summary_ids) for event in session.events], + "historical_events": [_event_snapshot(event, summary_ids) for event in session.historical_events], + "state": _canonicalize(session.state), + "memory": normalized_memory, + "summary": _canonicalize(summary_data), + "observed_errors": observed_errors, + } + return snapshot + + +def _snapshot_value(snapshot: dict[str, Any]) -> dict[str, Any]: + """Remove the diagnostic backend label before value comparison. + + 比较业务值前移除仅用于诊断的后端名称。 + """ + value = copy.deepcopy(snapshot) + value.pop("backend", None) + return value + + +def _allowed_rule(path: str, reference_backend: str, candidate_backend: str) -> Optional[AllowedDiff]: + """Find an explicit allowed-difference rule for one path and backend pair. + + 按字段路径和后端组合查找显式声明的允许差异规则。 + """ + for rule in ALLOWED_DIFFS: + if not fnmatch.fnmatch(path, rule.path): + continue + if reference_backend in rule.backends and candidate_backend in rule.backends: + return rule + return None + + +def compare_snapshots( + *, + case_id: str, + reference: dict[str, Any], + candidate: dict[str, Any], +) -> list[DiffEntry]: + """Recursively compare snapshots and return field-level diagnostics. + + 递归比较两个快照,并返回字段级、可定位的差异诊断。 + """ + differences: list[DiffEntry] = [] + reference_backend = reference["backend"] + candidate_backend = candidate["backend"] + session_id = reference["session_id"] + reference_value = _snapshot_value(reference) + candidate_value = _snapshot_value(candidate) + summary_value = reference_value.get("summary") or candidate_value.get("summary") or {} + summary_id = summary_value.get("summary_id") if isinstance(summary_value, dict) else None + + def add_difference(path: str, expected: Any, actual: Any) -> None: + """Attach session, event/Summary location and allowed-diff metadata. + + 为差异附加 Session、Event/Summary 定位信息及允许差异元数据。 + """ + segments = [segment for segment in path.split("/") if segment] + component = segments[0] if segments else "snapshot" + event_index = None + # Derive event_index from the JSON-pointer-like path so the report can + # locate an ordering/content defect without manually inspecting snapshots. + # 从类 JSON Pointer 路径提取 event_index,使报告可直接定位事件顺序或内容缺陷。 + match = re.match(r"^/(?:events|historical_events)/(\d+)", path) + if match: + event_index = int(match.group(1)) + rule = _allowed_rule(path, reference_backend, candidate_backend) + differences.append( + DiffEntry( + case_id=case_id, + session_id=session_id, + component=component, + field_path=path or "/", + reference_backend=reference_backend, + reference_value=expected, + candidate_backend=candidate_backend, + candidate_value=actual, + event_index=event_index, + summary_id=summary_id if component == "summary" else None, + allowed=rule is not None, + reason=rule.reason if rule else None, + ) + ) + + def walk(expected: Any, actual: Any, path: str) -> None: + """Walk dictionaries and lists without hiding missing or extra values. + + 递归遍历字典和列表,不掩盖缺失值或额外值。 + """ + if isinstance(expected, dict) and isinstance(actual, dict): + for key in sorted(set(expected) | set(actual)): + child_path = f"{path}/{key}" + if key not in expected: + add_difference(child_path, "", actual[key]) + elif key not in actual: + add_difference(child_path, expected[key], "") + else: + walk(expected[key], actual[key], child_path) + return + if isinstance(expected, list) and isinstance(actual, list): + common_length = min(len(expected), len(actual)) + for index in range(common_length): + walk(expected[index], actual[index], f"{path}/{index}") + for index in range(common_length, len(expected)): + add_difference(f"{path}/{index}", expected[index], "") + for index in range(common_length, len(actual)): + add_difference(f"{path}/{index}", "", actual[index]) + return + if expected != actual: + add_difference(path, expected, actual) + + walk(reference_value, candidate_value, "") + return differences + + +def _event_ids(snapshot: dict[str, Any], key: str) -> list[str]: + """Extract ordered Event IDs from an active or historical snapshot list. + + 从活跃或历史快照列表中按顺序提取 Event ID。 + """ + return [event["id"] for event in snapshot[key]] + + +def _memory_texts(snapshot: dict[str, Any], query: str) -> list[str]: + """Extract normalized Memory texts for fixture expectation checks. + + 提取规范化后的 Memory 文本,用于对照 fixture 预期。 + """ + texts: list[str] = [] + for entry in snapshot["memory"].get(query, []): + text = "".join(part.get("text", "") for part in entry["content"].get("parts", [])) + texts.append(_normalize_text(text)) + return sorted(texts) + + +def validate_expectations(case: ReplayCase, snapshot: dict[str, Any]) -> list[DiffEntry]: + """Validate one snapshot against fixtures, including InMemory-only mode. + + 将单后端快照与 fixture 预期比较,确保 InMemory 轻量模式也有检测能力。 + """ + expected = case.expect + differences: list[DiffEntry] = [] + + def check(path: str, expected_value: Any, actual_value: Any) -> None: + """Append a fixture-to-backend difference at an exact field path. + + 在精确字段路径记录一项 fixture 与后端结果的差异。 + """ + if expected_value == actual_value: + return + differences.append( + DiffEntry( + case_id=case.case_id, + session_id=snapshot["session_id"], + component=path.strip("/").split("/", maxsplit=1)[0], + field_path=path, + reference_backend="fixture", + reference_value=expected_value, + candidate_backend=snapshot["backend"], + candidate_value=actual_value, + ) + ) + + if "event_ids" in expected: + check("/events/ids", expected["event_ids"], _event_ids(snapshot, "events")) + if "historical_event_ids" in expected: + check( + "/historical_events/ids", + expected["historical_event_ids"], + _event_ids(snapshot, "historical_events"), + ) + if "state" in expected: + check("/state", _canonicalize(expected["state"]), snapshot["state"]) + if "summary" in expected: + expected_summary = expected["summary"] + if expected_summary is None: + check("/summary", None, snapshot["summary"]) + else: + actual_summary = snapshot["summary"] or {} + # Besides expected content, require exactly one active Summary and + # monotonic update times; these storage semantics cannot be normalized away. + # 除预期内容外,还严格要求唯一活跃 Summary 和单调更新时间,不能以归一化跳过。 + for key, expected_value in expected_summary.items(): + check(f"/summary/{key}", expected_value, actual_summary.get(key, "")) + check("/summary/active_summary_count", 1, actual_summary.get("active_summary_count", "")) + check("/summary/update_time_monotonic", True, actual_summary.get("update_time_monotonic", "")) + for query, expected_texts in expected.get("memory", {}).items(): + check(f"/memory/{query}", sorted(expected_texts), _memory_texts(snapshot, query)) + + all_ids = _event_ids(snapshot, "events") + _event_ids(snapshot, "historical_events") + check("/events/unique_ids", len(all_ids), len(set(all_ids))) + return differences + + +def mutate_snapshot(snapshot: dict[str, Any], mutation: str) -> dict[str, Any]: + """Inject one deterministic defect to measure comparator detection coverage. + + 注入一项确定性缺陷,用于衡量比较器的异常检出能力。 + """ + # Mutate a deep copy so fault injection never contaminates the valid + # backend snapshot subsequently written into the report. + # 在深拷贝上注入故障,避免污染随后写入报告的正常后端快照。 + mutated = copy.deepcopy(snapshot) + mutated["backend"] = f"{snapshot['backend']}-mutant-{mutation}" + + if mutation == "drop_event": + mutated["events"].pop() + elif mutation == "reorder_events": + mutated["events"][0], mutated["events"][1] = mutated["events"][1], mutated["events"][0] + elif mutation == "corrupt_tool_response": + for event in mutated["events"]: + for part in event.get("content", {}).get("parts", []): + if "function_response" in part: + part["function_response"]["response"] = {"temperature": -999} + return mutated + raise ValueError("No function response available to corrupt") + elif mutation == "stale_state": + first_key = next(iter(mutated["state"])) + mutated["state"][first_key] = "" + elif mutation == "leak_temp_state": + mutated["state"]["temp:leaked"] = "secret" + elif mutation == "drop_memory": + query = next(iter(mutated["memory"])) + mutated["memory"][query] = [] + elif mutation == "drop_summary": + mutated["summary"] = None + elif mutation == "wrong_summary_session": + mutated["summary"]["session_id"] = "another-session" + elif mutation == "stale_summary_version": + # A stale version must still be a valid persisted version. Version 1 + # has no earlier valid value, so fail instead of creating version 0 or + # silently returning an unchanged mutation. + # 陈旧版本也必须是合法持久化版本。版本 1 不存在更早的合法值,因此显式 + # 报错,不能制造版本 0,也不能静默返回一个没有变化的故障注入。 + current_version = mutated["summary"]["version"] + if (not isinstance(current_version, int) or isinstance(current_version, bool) + or current_version <= 1): + raise ValueError("stale_summary_version requires an integer summary version greater than one") + mutated["summary"]["version"] = current_version - 1 + elif mutation == "wrong_summary_replacement": + mutated["summary"]["replaces_summary_id"] = "summary:wrong" + elif mutation == "drop_retained_event": + mutated["events"].pop() + elif mutation == "duplicate_event": + mutated["events"].append(copy.deepcopy(mutated["events"][-1])) + else: + raise ValueError(f"Unknown replay mutation: {mutation}") + return mutated + + +# Give every public case one targeted mutation, proving the comparator detects +# each required defect category rather than merely passing valid snapshots. +# 每条公开用例对应一项定向故障,证明比较器确实能检出各类异常而非只验证正常快照。 +FAULT_BY_CASE = { + "single_turn_text": "drop_event", + "multi_turn_text": "reorder_events", + "tool_call_response": "corrupt_tool_response", + "session_state_overwrite": "stale_state", + "scoped_state_update": "leak_temp_state", + "memory_store_search": "drop_memory", + "summary_create": "drop_summary", + "summary_update_replace": "stale_summary_version", + "summary_truncate_continue": "drop_retained_event", + "failure_retry_duplicate": "duplicate_event", +} + + +async def run_replay_suite( + *, + cases_path: Path = DEFAULT_CASES_PATH, + report_path: Optional[Path] = None, + work_dir: Optional[Path] = None, + backend_names: Optional[list[str]] = None, +) -> dict[str, Any]: + """Replay all cases, compare results, inject faults, and build a JSON report. + + 回放全部用例、比较后端结果、注入故障,并构建可选落盘的 JSON 报告。 + """ + started_at = time.monotonic() + cases = load_replay_cases(cases_path) + names = backend_names or selected_backend_names() + owned_temp_dir: Optional[tempfile.TemporaryDirectory[str]] = None + if work_dir is None: + owned_temp_dir = tempfile.TemporaryDirectory(prefix="trpc-replay-") + work_dir = Path(owned_temp_dir.name) + work_dir.mkdir(parents=True, exist_ok=True) + + namespace = uuid.uuid4().hex[:12] + bundles: list[BackendBundle] = [] + case_reports: list[dict[str, Any]] = [] + total_differences = 0 + total_unallowed = 0 + + try: + # A fresh bundle per backend ensures every implementation receives the + # same configuration while retaining its native persistence behavior. + # 每个后端使用独立资源组合,在统一配置下保留各自原生持久化行为。 + for name in names: + backend_dir = work_dir / name + backend_dir.mkdir(parents=True, exist_ok=True) + bundles.append(await create_backend(name, backend_dir)) + + executor = ReplayExecutor(namespace) + for case in cases: + snapshots: dict[str, dict[str, Any]] = {} + differences: list[DiffEntry] = [] + for bundle in bundles: + snapshot = await executor.execute(case, bundle) + snapshots[bundle.name] = snapshot + differences.extend(validate_expectations(case, snapshot)) + + # Fixture checks make single-backend mode meaningful; pairwise + # checks then compare every additional backend with the reference. + # fixture 校验保证单后端模式仍有效;随后将其余后端逐一与基准后端比较。 + reference = snapshots[bundles[0].name] + for bundle in bundles[1:]: + differences.extend( + compare_snapshots( + case_id=case.case_id, + reference=reference, + candidate=snapshots[bundle.name], + ) + ) + + # Inject a known defect after valid replay. A missing diff here is + # a comparator false negative and is surfaced in the final report. + # 正常回放后注入已知缺陷;若未产生差异,即为比较器漏检并会写入最终报告。 + fault = FAULT_BY_CASE[case.case_id] + mutated = mutate_snapshot(reference, fault) + injected_differences = compare_snapshots( + case_id=case.case_id, + reference=reference, + candidate=mutated, + ) + + unallowed = [difference for difference in differences if not difference.allowed] + total_differences += len(differences) + total_unallowed += len(unallowed) + case_reports.append( + { + "case_id": case.case_id, + "status": "consistent" if not unallowed else "different", + "snapshots": snapshots, + "differences": [asdict(difference) for difference in differences], + "fault_injection": { + "mutation": fault, + "detected": bool(injected_differences), + "differences": [asdict(difference) for difference in injected_differences], + }, + } + ) + finally: + for bundle in bundles: + await bundle.close() + if owned_temp_dir is not None: + owned_temp_dir.cleanup() + + elapsed_seconds = round(time.monotonic() - started_at, 6) + report = { + "schema_version": 1, + "mode": "lightweight" if all(name in {"inmemory", "sqlite"} for name in names) else "integration", + "backends": names, + "case_count": len(cases), + "elapsed_seconds": elapsed_seconds, + "normalization_rules": NORMALIZATION_RULES, + "allowed_diff": [asdict(rule) for rule in ALLOWED_DIFFS], + "summary": { + "consistent_cases": sum(case["status"] == "consistent" for case in case_reports), + "different_cases": sum(case["status"] == "different" for case in case_reports), + "differences": total_differences, + "unallowed_differences": total_unallowed, + "faults_detected": sum(case["fault_injection"]["detected"] for case in case_reports), + }, + "cases": case_reports, + } + + if report_path is not None: + # Persist complete snapshots and field-level diagnostics so failures can + # be located by session ID, event index or Summary ID without rerunning. + # 持久化完整快照及字段级诊断,使问题可按 Session ID、事件索引或 Summary ID 直接定位。 + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return report + + +def _main() -> int: + """Run the command-line harness and return a CI-friendly exit status. + + 执行命令行回放框架,并返回适合 CI 判断的一致性退出码。 + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cases", type=Path, default=DEFAULT_CASES_PATH) + parser.add_argument("--report", type=Path, default=DEFAULT_REPORT_PATH) + parser.add_argument("--backends", default=None, help="Comma-separated backend names") + args = parser.parse_args() + backend_names = args.backends.split(",") if args.backends else None + report = asyncio.run( + run_replay_suite( + cases_path=args.cases, + report_path=args.report, + backend_names=backend_names, + ) + ) + print( + json.dumps( + { + "report": str(args.report), + **report["summary"], + "elapsed_seconds": report["elapsed_seconds"], + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 1 if report["summary"]["unallowed_differences"] else 0 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/tests/sessions/session_memory_summary_diff_report.json b/tests/sessions/session_memory_summary_diff_report.json deleted file mode 100644 index 81770660a..000000000 --- a/tests/sessions/session_memory_summary_diff_report.json +++ /dev/null @@ -1,538 +0,0 @@ -{ - "schema_version": 3, - "reference_backend": "in_memory", - "compared_backends": [ - "sqlite" - ], - "backend_statuses": [ - { - "name": "in_memory", - "status": "match", - "reason": null - }, - { - "name": "sqlite", - "status": "match", - "reason": null - }, - { - "name": "redis", - "status": "skipped", - "reason": "TRPC_REPLAY_REDIS_URL unset" - } - ], - "totals": { - "cases": 10, - "matched": 8, - "mismatched": 2, - "not_applicable": 0, - "skipped": 0 - }, - "false_positive_rate": 0.0, - "cases": [ - { - "case_id": "single_turn", - "session_id": "sess-single", - "status": "match", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "match", - "diffs": [], - "summary_issues": [] - } - ] - }, - { - "case_id": "multi_turn", - "session_id": "sess-multi", - "status": "match", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "match", - "diffs": [], - "summary_issues": [] - } - ] - }, - { - "case_id": "tool_round_trip", - "session_id": "sess-tool", - "status": "match", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "match", - "diffs": [], - "summary_issues": [] - } - ] - }, - { - "case_id": "state_overwrite", - "session_id": "sess-state", - "status": "match", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "match", - "diffs": [], - "summary_issues": [] - } - ] - }, - { - "case_id": "memory_preference", - "session_id": "sess-mem1", - "status": "match", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "match", - "diffs": [], - "summary_issues": [] - } - ] - }, - { - "case_id": "memory_fact_update", - "session_id": "sess-mem2", - "status": "match", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "match", - "diffs": [], - "summary_issues": [] - } - ] - }, - { - "case_id": "summary_create", - "session_id": "sess-sum1", - "status": "match", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "match", - "diffs": [], - "summary_issues": [] - } - ] - }, - { - "case_id": "summary_update", - "session_id": "sess-sum2", - "status": "mismatch", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "mismatch", - "diffs": [ - { - "session_id": "sess-sum2", - "event_index": 0, - "summary_id": null, - "field_path": "events[0].content.parts[0].text", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": "Previous conversation summary: DETERMINISTIC SUMMARY: [system] Previous conversation summary: DETERMINISTIC SUMMARY: [user] 聊东京 | [agent] 东京不错 | [user] 改去大阪 | [user] 改去大阪 | [agent] 大阪也好 | [agent] 大阪也好", - "candidate_value": "Previous conversation summary: DETERMINISTIC SUMMARY: [system] Previous conversation summary: DETERMINISTIC SUMMARY: [user] 聊东京 | [agent] 东京不错 | [user] 改去大阪 | [agent] 大阪也好", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-sum2", - "event_index": 4, - "summary_id": null, - "field_path": "historical_events[4].author", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": "user", - "candidate_value": "agent", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-sum2", - "event_index": 4, - "summary_id": null, - "field_path": "historical_events[4].content.parts[0].text", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": "改去大阪", - "candidate_value": "大阪也好", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-sum2", - "event_index": 4, - "summary_id": null, - "field_path": "historical_events[4].content.role", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": "user", - "candidate_value": "model", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-sum2", - "event_index": 5, - "summary_id": null, - "field_path": "historical_events[5]", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": { - "content": { - "parts": [ - { - "media_resolution": null, - "code_execution_result": null, - "executable_code": null, - "file_data": null, - "function_call": null, - "function_response": null, - "inline_data": null, - "text": "大阪也好", - "thought": null, - "thought_signature": null, - "video_metadata": null, - "tool_call": null, - "tool_response": null, - "part_metadata": null - } - ], - "role": "model" - }, - "grounding_metadata": null, - "partial": null, - "turn_complete": null, - "error_code": null, - "error_message": null, - "interrupted": null, - "custom_metadata": null, - "usage_metadata": null, - "response_id": null, - "invocation_id": "", - "author": "agent", - "actions": { - "skip_summarization": null, - "state_delta": {}, - "artifact_delta": {}, - "transfer_to_agent": null, - "escalate": null - }, - "long_running_tool_ids": null, - "branch": null, - "request_id": null, - "parent_invocation_id": null, - "tag": null, - "filter_key": null, - "requires_completion": false, - "version": 0, - "id": "", - "timestamp": "", - "visible": true, - "object": null, - "model_flags": 1 - }, - "candidate_value": "", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-sum2", - "event_index": 6, - "summary_id": null, - "field_path": "historical_events[6]", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": { - "content": { - "parts": [ - { - "media_resolution": null, - "code_execution_result": null, - "executable_code": null, - "file_data": null, - "function_call": null, - "function_response": null, - "inline_data": null, - "text": "大阪也好", - "thought": null, - "thought_signature": null, - "video_metadata": null, - "tool_call": null, - "tool_response": null, - "part_metadata": null - } - ], - "role": "model" - }, - "grounding_metadata": null, - "partial": null, - "turn_complete": null, - "error_code": null, - "error_message": null, - "interrupted": null, - "custom_metadata": null, - "usage_metadata": null, - "response_id": null, - "invocation_id": "", - "author": "agent", - "actions": { - "skip_summarization": null, - "state_delta": {}, - "artifact_delta": {}, - "transfer_to_agent": null, - "escalate": null - }, - "long_running_tool_ids": null, - "branch": null, - "request_id": null, - "parent_invocation_id": null, - "tag": null, - "filter_key": null, - "requires_completion": false, - "version": 0, - "id": "", - "timestamp": "", - "visible": true, - "object": null, - "model_flags": 1 - }, - "candidate_value": "", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-sum2", - "event_index": null, - "summary_id": "sess-sum2:summary", - "field_path": "summary.current.original_event_count", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": 5, - "candidate_value": 3, - "allowed": false, - "reason": null - }, - { - "session_id": "sess-sum2", - "event_index": null, - "summary_id": "sess-sum2:summary", - "field_path": "summary.current.text", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": "DETERMINISTIC SUMMARY: [system] Previous conversation summary: DETERMINISTIC SUMMARY: [user] 聊东京 | [agent] 东京不错 | [user] 改去大阪 | [user] 改去大阪 | [agent] 大阪也好 | [agent] 大阪也好", - "candidate_value": "DETERMINISTIC SUMMARY: [system] Previous conversation summary: DETERMINISTIC SUMMARY: [user] 聊东京 | [agent] 东京不错 | [user] 改去大阪 | [agent] 大阪也好", - "allowed": false, - "reason": null - } - ], - "summary_issues": [] - } - ] - }, - { - "case_id": "summary_truncation", - "session_id": "sess-trunc", - "status": "mismatch", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "mismatch", - "diffs": [ - { - "session_id": "sess-trunc", - "event_index": 2, - "summary_id": null, - "field_path": "events[2].author", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": "user", - "candidate_value": "agent", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-trunc", - "event_index": 2, - "summary_id": null, - "field_path": "events[2].content.parts[0].text", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": "压缩后的新问题", - "candidate_value": "新回复", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-trunc", - "event_index": 2, - "summary_id": null, - "field_path": "events[2].content.role", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": "user", - "candidate_value": "model", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-trunc", - "event_index": 3, - "summary_id": null, - "field_path": "events[3]", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": { - "content": { - "parts": [ - { - "media_resolution": null, - "code_execution_result": null, - "executable_code": null, - "file_data": null, - "function_call": null, - "function_response": null, - "inline_data": null, - "text": "新回复", - "thought": null, - "thought_signature": null, - "video_metadata": null, - "tool_call": null, - "tool_response": null, - "part_metadata": null - } - ], - "role": "model" - }, - "grounding_metadata": null, - "partial": null, - "turn_complete": null, - "error_code": null, - "error_message": null, - "interrupted": null, - "custom_metadata": null, - "usage_metadata": null, - "response_id": null, - "invocation_id": "", - "author": "agent", - "actions": { - "skip_summarization": null, - "state_delta": {}, - "artifact_delta": {}, - "transfer_to_agent": null, - "escalate": null - }, - "long_running_tool_ids": null, - "branch": null, - "request_id": null, - "parent_invocation_id": null, - "tag": null, - "filter_key": null, - "requires_completion": false, - "version": 0, - "id": "", - "timestamp": "", - "visible": true, - "object": null, - "model_flags": 1 - }, - "candidate_value": "", - "allowed": false, - "reason": null - }, - { - "session_id": "sess-trunc", - "event_index": 4, - "summary_id": null, - "field_path": "events[4]", - "reference_backend": "in_memory", - "candidate_backend": "sqlite", - "reference_value": { - "content": { - "parts": [ - { - "media_resolution": null, - "code_execution_result": null, - "executable_code": null, - "file_data": null, - "function_call": null, - "function_response": null, - "inline_data": null, - "text": "新回复", - "thought": null, - "thought_signature": null, - "video_metadata": null, - "tool_call": null, - "tool_response": null, - "part_metadata": null - } - ], - "role": "model" - }, - "grounding_metadata": null, - "partial": null, - "turn_complete": null, - "error_code": null, - "error_message": null, - "interrupted": null, - "custom_metadata": null, - "usage_metadata": null, - "response_id": null, - "invocation_id": "", - "author": "agent", - "actions": { - "skip_summarization": null, - "state_delta": {}, - "artifact_delta": {}, - "transfer_to_agent": null, - "escalate": null - }, - "long_running_tool_ids": null, - "branch": null, - "request_id": null, - "parent_invocation_id": null, - "tag": null, - "filter_key": null, - "requires_completion": false, - "version": 0, - "id": "", - "timestamp": "", - "visible": true, - "object": null, - "model_flags": 1 - }, - "candidate_value": "", - "allowed": false, - "reason": null - } - ], - "summary_issues": [] - } - ] - }, - { - "case_id": "retry_recovery", - "session_id": "sess-retry", - "status": "match", - "comparisons": [ - { - "candidate_backend": "sqlite", - "status": "match", - "diffs": [], - "summary_issues": [] - } - ] - } - ], - "known_drift_cases": [ - "summary_truncation", - "summary_update" - ] -} \ No newline at end of file diff --git a/tests/sessions/test_base_session_service.py b/tests/sessions/test_base_session_service.py index bcb63037a..04880258b 100644 --- a/tests/sessions/test_base_session_service.py +++ b/tests/sessions/test_base_session_service.py @@ -102,6 +102,25 @@ async def test_append_event_basic(self): assert len(session.events) == 1 assert session.events[0] is event + async def test_append_event_same_id_is_idempotent(self): + """Verify duplicate Event IDs are appended only once. + + 验证同一 Session 中重复的 Event ID 只会追加一次,且重试返回已存事件。 + """ + svc = ConcreteSessionService() + session = _make_session() + event = _make_event() + + # Append once, then retry with a distinct object carrying the same ID. + # 首次追加后,使用具有相同 ID 的独立对象模拟重试。 + await svc.append_event(session, event) + result = await svc.append_event(session, event.model_copy(deep=True)) + + # The retry resolves to the stored ID without duplicating the event window. + # 重试返回已存 ID,且事件窗口中不会出现重复项。 + assert result.id == event.id + assert [stored.id for stored in session.events] == [event.id] + async def test_append_event_partial_skipped(self): svc = ConcreteSessionService() session = _make_session() diff --git a/tests/sessions/test_in_memory_session_service.py b/tests/sessions/test_in_memory_session_service.py index 174daa311..58ed9ed46 100644 --- a/tests/sessions/test_in_memory_session_service.py +++ b/tests/sessions/test_in_memory_session_service.py @@ -379,6 +379,47 @@ async def test_append_updates_conversation_count(self): assert stored_session.conversation_count == 5 await svc.close() + @pytest.mark.parametrize("store_historical_events", [False, True]) + async def test_append_mirrors_filtered_event_windows_to_storage(self, store_historical_events): + """Keep stored active/history windows aligned after max-events filtering. + + max_events 过滤后,确保存储中的活动/历史事件窗口与调用方 Session 一致。 + """ + config = _make_session_config( + max_events=2, + store_historical_events=store_historical_events, + ) + svc = InMemorySessionService(session_config=config) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + + # A user event acts as the retained conversation anchor while later + # agent events force max_events filtering on both event windows. + # user 事件作为保留锚点,后续 agent 事件触发两个事件窗口的 max_events 过滤。 + for index in range(6): + event = _make_event( + author="user" if index == 2 else "agent", + text=f"message-{index}", + ) + event.id = f"event-{index}" + await svc.append_event(session, event) + + expected_active_ids = ["event-2", "event-5"] + expected_historical_ids = (["event-0", "event-1", "event-3", "event-4"] if store_historical_events else []) + assert [event.id for event in session.events] == expected_active_ids + assert [event.id for event in session.historical_events] == expected_historical_ids + + # Inspect the raw stored snapshot so get_session read-view filtering + # cannot conceal persistence-window drift. + # 直接检查原始存储快照,避免 get_session 的读取过滤掩盖持久化窗口漂移。 + stored_session = svc._get_session("app", "user", "s1") + assert [event.id for event in stored_session.events] == expected_active_ids + assert [event.id for event in stored_session.historical_events] == expected_historical_ids + # Copy the list containers without sharing caller-owned windows. + # 复制列表容器,避免与调用方共享可变的事件窗口。 + assert stored_session.events is not session.events + assert stored_session.historical_events is not session.historical_events + await svc.close() + # --------------------------------------------------------------------------- # InMemorySessionService — update_session @@ -394,6 +435,34 @@ async def test_update_existing(self): await svc.update_session(session) await svc.close() + async def test_append_after_update_does_not_share_event_list_with_storage(self): + """Verify update_session stores a deep-isolated event list. + + 验证 update_session 保存的是深度隔离副本,后续追加不会因调用方与存储 + 共享 events 列表而产生重复事件。 + """ + config = _make_session_config(store_historical_events=True) + svc = InMemorySessionService(session_config=config) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + first = _make_event(text="first") + first.id = "event-1" + second = _make_event(text="second") + second.id = "event-2" + + # Persist an initial event and replace storage with the session snapshot. + # 先持久化首个事件,再用当前 Session 快照更新存储。 + await svc.append_event(session, first) + await svc.update_session(session) + # If update_session retained a shared list, this append would appear twice. + # 若 update_session 保留了共享列表,此次追加会在存储中出现两次。 + await svc.append_event(session, second) + + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + # Reload storage to prove both IDs exist exactly once and in order. + # 重新读取存储,确认两个 ID 均只出现一次且顺序正确。 + assert [event.id for event in stored.events] == ["event-1", "event-2"] + await svc.close() + async def test_update_nonexistent_app(self): svc = InMemorySessionService(session_config=_make_session_config()) session = Session(id="s1", app_name="nonexistent", user_id="user", save_key="k") diff --git a/tests/sessions/test_redis_session_service.py b/tests/sessions/test_redis_session_service.py index 8269b1862..836abf99f 100644 --- a/tests/sessions/test_redis_session_service.py +++ b/tests/sessions/test_redis_session_service.py @@ -61,12 +61,14 @@ class _MockRedisStorage: def __init__(self): self._store = {} self._hash_store = {} + self.commands = [] @asynccontextmanager async def create_db_session(self): yield MagicMock() async def execute_command(self, session, command): + self.commands.append(command) method = command.method args = command.args @@ -83,11 +85,18 @@ async def execute_command(self, session, command): return [k for k in self._store.keys() if k.startswith(prefix)] elif method == 'hset': key = args[0] - pairs = args[1:] if key not in self._hash_store: self._hash_store[key] = {} - for i in range(0, len(pairs), 2): - self._hash_store[key][pairs[i]] = pairs[i + 1] + mapping = command.kwargs.get("mapping") + if mapping is not None: + self._hash_store[key].update(mapping) + else: + # Retain legacy positional support so unrelated storage tests + # can still model direct single-field HSET commands. + # 保留旧位置参数支持,使无关存储测试仍可模拟单字段 HSET。 + pairs = args[1:] + for i in range(0, len(pairs), 2): + self._hash_store[key][pairs[i]] = pairs[i + 1] return True elif method == 'hgetall': key = args[0] @@ -143,6 +152,33 @@ async def test_create_with_state(self): assert session.state[f"{State.USER_PREFIX}uk"] == "uv" await svc.close() + async def test_create_multiple_scoped_state_fields_uses_hset_mapping(self): + """Write multi-field app/user state through redis-py's mapping API. + + 通过 redis-py mapping API 写入多字段 app/user state。 + """ + svc = _create_service() + session = await svc.create_session( + app_name="app", + user_id="user", + session_id="s1", + state={ + f"{State.APP_PREFIX}region": "cn", + f"{State.APP_PREFIX}timezone": "utc8", + f"{State.USER_PREFIX}language": "zh", + f"{State.USER_PREFIX}theme": "dark", + }, + ) + + hset_commands = [command for command in svc._redis_storage.commands if command.method == "hset"] + assert len(hset_commands) == 2 + assert all(len(command.args) == 1 for command in hset_commands) + assert hset_commands[0].kwargs["mapping"] == {"region": "cn", "timezone": "utc8"} + assert hset_commands[1].kwargs["mapping"] == {"language": "zh", "theme": "dark"} + assert session.state[f"{State.APP_PREFIX}region"] == "cn" + assert session.state[f"{State.USER_PREFIX}language"] == "zh" + await svc.close() + async def test_create_with_whitespace_id(self): svc = _create_service() session = await svc.create_session(app_name="app", user_id="user", session_id=" ") @@ -273,6 +309,28 @@ async def test_append_with_state_delta(self): assert stored.state[f"{State.USER_PREFIX}user_key"] == "uv" await svc.close() + async def test_append_overwrites_raw_bytes_scoped_state(self): + """Decode raw Redis Hash state before applying string-key deltas. + + 应用字符串键 delta 前先解码 Redis Hash 原始 bytes state。 + """ + svc = _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + svc._redis_storage._hash_store["app_state:app"] = {b"region": b"cn"} + svc._redis_storage._hash_store["user_state:app:user"] = {b"language": b"zh"} + + event = _make_event(state_delta={ + f"{State.APP_PREFIX}region": "global", + f"{State.USER_PREFIX}language": "en", + }) + await svc.append_event(session, event) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + + assert stored.state[f"{State.APP_PREFIX}region"] == "global" + assert stored.state[f"{State.USER_PREFIX}language"] == "en" + assert all("b'" not in key for key in stored.state) + await svc.close() + async def test_append_does_not_persist_merged_or_temp_state_in_session_json(self): svc = _create_service() session = await svc.create_session(app_name="app", user_id="user", session_id="s1") diff --git a/tests/sessions/test_replay_consistency.py b/tests/sessions/test_replay_consistency.py index 7219604bf..19e0e1aec 100644 --- a/tests/sessions/test_replay_consistency.py +++ b/tests/sessions/test_replay_consistency.py @@ -6,7 +6,7 @@ """Replay 一致性 E2E:同一组 case 驱动多后端,比较事件/state/memory/summary。 轻量模式默认 InMemory vs SQLite(:memory:);Redis 经 TRPC_REPLAY_REDIS_URL 启用。 -报告产物:tests/sessions/session_memory_summary_diff_report.json。 +报告产物:tests/sessions/artifacts/session_memory_summary_diff_report.json。 """ from __future__ import annotations @@ -29,14 +29,13 @@ from tests.sessions.replay.summary_checks import check_summary_issues CASES_DIR = str(Path(__file__).parent / "replay" / "replay_cases") -REPORT_PATH = str(Path(__file__).parent / "session_memory_summary_diff_report.json") +REPORT_PATH = str(Path(__file__).parent / "artifacts" / "session_memory_summary_diff_report.json") LIGHTWEIGHT_TIMEOUT = 30 # 验收第 6 条:轻量模式 ≤30s -KNOWN_DRIFT = {"summary_update", "summary_truncation"} -"""已知 SQLite summary 持久化漂移:``create_session_summary`` 后 SQLite ``get_session`` -读回的 events 顺序 / historical_events / summary 与 InMemory 不一致(类 issue #163 的 -summarizer 锚点 timestamp 问题)。框架正确发现,按设计 §8「只报告不改」记录, -不计入误报率分母,修 bug 另开 issue/PR。""" +KNOWN_DRIFT: set[str] = set() +"""当前没有已知漂移。原 SQLite Summary 锚点顺序问题已由版本化摘要事件、 +毫秒级排序间隔和持久化恢复语义修复;若以后发现新的真实后端差异,可在此 +显式登记并保留报告定位。""" def _find(case_id: str): diff --git a/tests/sessions/test_replay_harness_consistency.py b/tests/sessions/test_replay_harness_consistency.py new file mode 100644 index 000000000..aaad13802 --- /dev/null +++ b/tests/sessions/test_replay_harness_consistency.py @@ -0,0 +1,153 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Cross-backend Session / Memory / Summary replay harness tests. + +Session / Memory / Summary 跨后端回放框架测试。 +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from .replay.redis_support import require_replay_redis +from .replay_harness import DEFAULT_CASES_PATH +from .replay_harness import DEFAULT_REPORT_PATH +from .replay_harness import compare_snapshots +from .replay_harness import load_replay_cases +from .replay_harness import mutate_snapshot +from .replay_harness import run_replay_suite + + +def test_public_replay_case_catalog_has_ten_unique_cases(): + """Require exactly ten uniquely named public replay cases. + + 确保公开回放目录恰好包含十条名称唯一的用例。 + """ + cases = load_replay_cases(DEFAULT_CASES_PATH) + assert len(cases) == 10 + assert len({case.case_id for case in cases}) == 10 + + +def test_default_report_path_uses_test_artifact_directory(): + """Keep generated CLI reports out of tracked source locations. + + 确保 CLI 生成报告只写入测试产物目录,不污染受版本控制的源码位置。 + """ + expected = Path(__file__).parent / "artifacts" / "session_memory_summary_diff_report.json" + assert DEFAULT_REPORT_PATH == expected + + +async def test_inmemory_and_sqlite_replay_consistency(tmp_path): + """Verify InMemory and SQLite agree and detect all injected defects. + + 验证 InMemory 与 SQLite 结果一致,并检出全部注入故障。 + """ + report_path = tmp_path / "session_memory_summary_diff_report.json" + report = await run_replay_suite( + report_path=report_path, + work_dir=tmp_path / "backends", + backend_names=["inmemory", "sqlite"], + ) + + assert report_path.exists() + assert report["case_count"] == 10 + assert report["summary"]["consistent_cases"] == 10 + assert report["summary"]["unallowed_differences"] == 0 + assert report["summary"]["faults_detected"] == 10 + assert report["elapsed_seconds"] <= 30 + + +async def test_inmemory_only_lightweight_mode(tmp_path): + """Keep fixture and fault checks effective in lightweight InMemory mode. + + 验证轻量 InMemory 模式仍执行 fixture 校验和故障检出。 + """ + report = await run_replay_suite( + work_dir=tmp_path, + backend_names=["inmemory"], + ) + + assert report["case_count"] == 10 + assert report["summary"]["consistent_cases"] == 10 + assert report["summary"]["unallowed_differences"] == 0 + assert report["summary"]["faults_detected"] == 10 + assert report["elapsed_seconds"] <= 30 + + +async def test_summary_faults_are_all_located(tmp_path): + """Locate Summary loss, ownership, version, and replacement defects. + + 精确定位 Summary 丢失、归属、版本及覆盖关系错误。 + """ + report = await run_replay_suite( + work_dir=tmp_path, + backend_names=["inmemory"], + ) + snapshots = {case["case_id"]: case["snapshots"]["inmemory"] for case in report["cases"]} + + checks = [ + ("summary_create", "drop_summary", "/summary"), + ("summary_create", "wrong_summary_session", "/summary/session_id"), + ("summary_update_replace", "stale_summary_version", "/summary/version"), + ("summary_update_replace", "wrong_summary_replacement", "/summary/replaces_summary_id"), + ] + for case_id, mutation, expected_path in checks: + reference = snapshots[case_id] + candidate = mutate_snapshot(reference, mutation) + differences = compare_snapshots( + case_id=case_id, + reference=reference, + candidate=candidate, + ) + assert differences + assert any(difference.field_path == expected_path for difference in differences) + assert all(difference.session_id for difference in differences) + assert all(difference.reference_backend and difference.candidate_backend for difference in differences) + + +def test_stale_summary_version_mutation_remains_valid(): + """Produce a valid older version and reject an impossible v1 mutation. + + 生成合法的旧版本,并拒绝无法再回退的 v1 摘要故障注入。 + """ + reference = {"backend": "inmemory", "summary": {"version": 2}} + assert mutate_snapshot(reference, "stale_summary_version")["summary"]["version"] == 1 + + reference["summary"]["version"] = 1 + with pytest.raises(ValueError, match="greater than one"): + mutate_snapshot(reference, "stale_summary_version") + + +async def test_optional_redis_integration(tmp_path): + """Compare Redis when its opt-in integration URL is configured. + + 配置可选集成 URL 时,对比 Redis 后端的一致性。 + """ + # Check the actual endpoint instead of treating the mere presence of an + # environment variable as proof that Redis is running. + # 检查真实端点,不能仅凭环境变量存在就认为 Redis 已启动。 + require_replay_redis() + report = await run_replay_suite( + work_dir=tmp_path, + backend_names=["inmemory", "redis"], + ) + assert report["summary"]["unallowed_differences"] == 0 + + +@pytest.mark.skipif(not os.getenv("TRPC_REPLAY_SQL_URL"), reason="TRPC_REPLAY_SQL_URL is not configured") +async def test_optional_sql_integration(tmp_path): + """Compare a real SQL backend when its integration URL is configured. + + 配置集成 URL 时,对比真实 SQL 后端的一致性。 + """ + report = await run_replay_suite( + work_dir=tmp_path, + backend_names=["inmemory", "sql"], + ) + assert report["summary"]["unallowed_differences"] == 0 diff --git a/tests/sessions/test_replay_injections.py b/tests/sessions/test_replay_injections.py index 124a13e8d..83e95a97b 100644 --- a/tests/sessions/test_replay_injections.py +++ b/tests/sessions/test_replay_injections.py @@ -13,11 +13,8 @@ from __future__ import annotations -import os from pathlib import Path -import pytest - from tests.sessions.replay.backends import in_memory_backend from tests.sessions.replay.backends import sqlite_backend from tests.sessions.replay.comparator import compare_snapshots @@ -28,6 +25,7 @@ from tests.sessions.replay.injectors import inject_snapshot_diff from tests.sessions.replay.injectors import inject_sql_diff from tests.sessions.replay.normalizer import normalize_snapshot +from tests.sessions.replay.redis_support import require_replay_redis from tests.sessions.replay.summary_checks import check_summary_issues CASES_DIR = str(Path(__file__).parent / "replay" / "replay_cases") @@ -184,9 +182,10 @@ async def test_state_value_drift_detected(self, tmp_path): class TestEndToEndRedisInjection: async def test_event_author_drift_detected(self): - redis_url = os.environ.get("TRPC_REPLAY_REDIS_URL") - if not redis_url: - pytest.skip("TRPC_REPLAY_REDIS_URL unset") + # Resolve and probe Redis before replaying so an unavailable optional + # service is reported as skipped instead of producing a long traceback. + # 回放前解析并探测 Redis,使不可用的可选服务明确标记为跳过。 + redis_url = require_replay_redis() from tests.sessions.replay.backends import redis_backend case = _find("single_turn") @@ -227,9 +226,9 @@ async def test_state_value_drift_detected(self): """验收 state_value 端到端测试(helloopenworld review 建议):注入后读回 app_state, 断言含注入键且与 SDK 写入格式可被 compare_snapshots 正确比较。 """ - redis_url = os.environ.get("TRPC_REPLAY_REDIS_URL") - if not redis_url: - pytest.skip("TRPC_REPLAY_REDIS_URL unset") + # Reuse the same reachability guard as the integration suite. + # 与集成一致性测试复用同一可达性检查。 + redis_url = require_replay_redis() from tests.sessions.replay.backends import redis_backend case = _find("state_overwrite") diff --git a/tests/sessions/test_replay_unit.py b/tests/sessions/test_replay_unit.py index 6eee2f74a..aeb76b789 100644 --- a/tests/sessions/test_replay_unit.py +++ b/tests/sessions/test_replay_unit.py @@ -26,6 +26,7 @@ from tests.sessions.replay.report import CaseResult from tests.sessions.replay.report import Comparison from tests.sessions.replay.report import build_diff_report +from tests.sessions.replay.redis_support import redis_unavailable_reason from tests.sessions.replay.summary_checks import check_summary_issues from tests.sessions.replay.summary_checks import summary_text_similarity @@ -86,6 +87,33 @@ def test_normalize_event_replaces_volatile_fields(self): assert e["invocation_id"] == NORMALIZED assert e["author"] == "user" + def test_normalize_event_replaces_only_volatile_summary_metadata(self): + """Normalize generated Summary values without hiding its semantics. + + 归一化自动生成的 Summary 值,但保留归属、正文、版本和覆盖关系语义。 + """ + e = normalize_event({ + "id": "summary-2", + "timestamp": 2.0, + "custom_metadata": { + "session_summary": { + "summary_id": "summary-2", + "summary_timestamp": 2.0, + "replaces_summary_id": "summary-1", + "session_id": "session-a", + "summary_text": "stable text", + "version": 2, + } + }, + }) + metadata = e["custom_metadata"]["session_summary"] + assert metadata["summary_id"] == NORMALIZED + assert metadata["summary_timestamp"] == NORMALIZED + assert metadata["replaces_summary_id"] == NORMALIZED + assert metadata["session_id"] == "session-a" + assert metadata["summary_text"] == "stable text" + assert metadata["version"] == 2 + def test_normalize_strips_temp_state(self): snap = normalize_snapshot(_snapshot(state={"app:x": 1, "temp:skip": 2, "plain": 3})) assert "temp:skip" not in snap.state @@ -338,3 +366,44 @@ def test_report_fpr_excludes_known_drift(self): # FPR 只按正常 case:normal_mismatch=1 / normal_total=2 = 0.5,drift 不进分子分母 assert report["false_positive_rate"] == 0.5 assert report["known_drift_cases"] == ["drift_one"] + + +# --------------------------------------------------------------------------- +# Task 10: Redis availability +# --------------------------------------------------------------------------- + + +class TestRedisSupport: + + def test_probe_reports_reachability_and_closes_client(self, monkeypatch): + """验证 Redis PING 成功/失败,并确保连接和凭据得到安全处理。""" + + class FakeClient: + + def __init__(self, error=None): + self.error = error + self.closed = False + + def ping(self): + if self.error: + raise self.error + return True + + def close(self): + self.closed = True + + available = FakeClient() + unavailable = FakeClient(ConnectionError("refused")) + clients = [available, unavailable] + monkeypatch.setattr( + "tests.sessions.replay.redis_support.redis.Redis.from_url", + lambda *_args, **_kwargs: clients.pop(0), + ) + + assert redis_unavailable_reason("redis://localhost:6379/0") is None + assert available.closed + + reason = redis_unavailable_reason("redis://:top-secret@localhost:6379/0") + assert "ConnectionError: refused" in reason + assert "top-secret" not in reason + assert unavailable.closed diff --git a/tests/sessions/test_session_summarizer.py b/tests/sessions/test_session_summarizer.py index ef46db4bb..cacf206f0 100644 --- a/tests/sessions/test_session_summarizer.py +++ b/tests/sessions/test_session_summarizer.py @@ -15,6 +15,7 @@ from __future__ import annotations import time +from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock import pytest @@ -23,8 +24,10 @@ from trpc_agent_sdk.sessions._session import Session from trpc_agent_sdk.sessions._session_summarizer import ( DEFAULT_SUMMARIZER_PROMPT, + SESSION_SUMMARY_METADATA_KEY, SessionSummarizer, SessionSummary, + session_summary_from_event, ) from trpc_agent_sdk.types import Content, EventActions, FunctionCall, FunctionResponse, Part @@ -49,6 +52,17 @@ def _make_event(author="agent", text="hello", partial=False, branch=None, skip_s ) +def _make_persisted_summary_event(metadata: dict[str, object]) -> Event: + """Build a persisted summary event for recovery tests. + + 构造持久化摘要事件,用于验证恢复和异常元数据处理。 + """ + event = _make_event(text="summary text") + event.custom_metadata = {SESSION_SUMMARY_METADATA_KEY: metadata} + event.set_summary_event(True) + return event + + def _make_model_mock(): model = MagicMock() model.name = "test-model" @@ -91,6 +105,55 @@ def test_get_compression_ratio_no_compression(self): assert summary.get_compression_ratio() == 0.0 + def test_from_event_converts_numeric_metadata(self): + """Recover valid legacy numeric strings without losing compatibility. + + 验证合法的旧式数字字符串仍可恢复,保持存量数据兼容性。 + """ + event = _make_persisted_summary_event({ + "session_id": "s1", + "summary_text": "persisted summary", + "version": "2", + "original_event_count": "8", + "compressed_event_count": "3", + "summary_timestamp": "123.5", + }) + + summary = session_summary_from_event(event) + + assert summary is not None + assert summary.version == 2 + assert summary.original_event_count == 8 + assert summary.compressed_event_count == 3 + assert summary.summary_timestamp == 123.5 + + @pytest.mark.parametrize( + ("field", "invalid_value"), + [ + ("version", {"invalid": 1}), + ("original_event_count", "not-an-integer"), + ("compressed_event_count", None), + ("summary_timestamp", "not-a-timestamp"), + ], + ) + def test_from_event_rejects_malformed_numeric_metadata(self, field, invalid_value): + """Return None rather than raising for malformed persisted anchors. + + 持久化数字锚点畸形时返回 None,而不是抛异常中断摘要恢复。 + """ + metadata = { + "session_id": "s1", + "summary_text": "persisted summary", + "version": 2, + "original_event_count": 8, + "compressed_event_count": 3, + "summary_timestamp": 123.5, + } + metadata[field] = invalid_value + + assert session_summary_from_event(_make_persisted_summary_event(metadata)) is None + + # --------------------------------------------------------------------------- # SessionSummarizer — should_summarize # --------------------------------------------------------------------------- @@ -353,6 +416,35 @@ async def mock_generate(request, stream=False, ctx=None): assert summary_event.author == "system" assert summary_event.content.role == "user" + async def test_summary_ordering_gap_survives_sql_timestamp_rounding(self): + """Keep the Summary anchor distinct after SQL timestamp conversion. + + 确保 Summary 排序锚点转换为 SQL 时间戳后仍早于首个保留事件。 + """ + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="summary text")]) + + async def mock_generate(request, stream=False, ctx=None): + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model) + events = [_make_event(text=f"msg{i}") for i in range(4)] + for index, event in enumerate(events): + event.timestamp = 1_775_000_000.0451305 + index * 0.01 + + _, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=2) + + # Follow the production float-to-datetime conversion and require a + # gap wider than SQL's one-microsecond precision. + # 按生产逻辑把浮点时间转换为 datetime,并要求间隔大于 SQL 的一微秒精度。 + assert result_events[0].is_summary_event() + summary_storage_time = datetime.fromtimestamp(result_events[0].timestamp) + retained_storage_time = datetime.fromtimestamp(result_events[1].timestamp) + assert retained_storage_time - summary_storage_time > timedelta(microseconds=1) + async def test_summary_starts_from_first_user_turn_before_recent_events(self): model = _make_model_mock() llm_response = MagicMock() diff --git a/tests/sessions/test_summarizer_manager.py b/tests/sessions/test_summarizer_manager.py index fa3292983..f9ea184ab 100644 --- a/tests/sessions/test_summarizer_manager.py +++ b/tests/sessions/test_summarizer_manager.py @@ -21,6 +21,7 @@ from trpc_agent_sdk.events import Event from trpc_agent_sdk.sessions._session import Session from trpc_agent_sdk.sessions._session_summarizer import SessionSummarizer, SessionSummary +from trpc_agent_sdk.sessions._session_summarizer import SESSION_SUMMARY_METADATA_KEY from trpc_agent_sdk.sessions._summarizer_manager import SummarizerSessionManager from trpc_agent_sdk.types import Content, Part @@ -220,6 +221,73 @@ async def test_get_no_cache(self): result = await manager.get_session_summary(session) assert result is None + async def test_get_rebuilds_cache_from_persisted_summary_event(self): + """Recover full version metadata after cache loss. + + 验证缓存丢失后可从持久化摘要事件恢复版本、覆盖链和实际更新时间。 + """ + model = _make_model() + manager = SummarizerSessionManager(model=model) + summary_event = Event( + id="summary-2", + invocation_id="summary", + author="system", + version=2, + timestamp=123.0, + content=Content(parts=[Part.from_text(text="Previous conversation summary: persisted")]), + custom_metadata={ + SESSION_SUMMARY_METADATA_KEY: { + "session_id": "s1", + "summary_text": "persisted", + "version": 2, + "replaces_summary_id": "summary-1", + "original_event_count": 8, + "compressed_event_count": 3, + "summary_timestamp": 456.0, + } + }, + ) + summary_event.set_summary_event(True) + session = _make_session(events=[summary_event]) + + result = await manager.get_session_summary(session) + + assert result.summary_id == "summary-2" + assert result.session_id == "s1" + assert result.summary_text == "persisted" + assert result.version == 2 + assert result.replaces_summary_id == "summary-1" + assert result.summary_timestamp == 456.0 + assert manager._summarizer_cache["app"]["user"]["s1"] is result + + async def test_get_rejects_summary_owned_by_another_session(self): + """Reject a persisted summary whose explicit owner is another session. + + 验证显式归属于其他会话的持久化摘要不会被当前会话错误加载。 + """ + model = _make_model() + manager = SummarizerSessionManager(model=model) + summary_event = Event( + id="summary-wrong-owner", + invocation_id="summary", + author="system", + content=Content(parts=[Part.from_text(text="Previous conversation summary: wrong")]), + custom_metadata={ + SESSION_SUMMARY_METADATA_KEY: { + "session_id": "another-session", + "summary_text": "wrong", + "version": 1, + "original_event_count": 4, + "compressed_event_count": 2, + "summary_timestamp": 123.0, + } + }, + ) + summary_event.set_summary_event(True) + session = _make_session(events=[summary_event]) + + assert await manager.get_session_summary(session) is None + async def test_get_no_summarizer(self): model = _make_model() manager = SummarizerSessionManager(model=model) diff --git a/trpc_agent_sdk/memory/_sql_memory_service.py b/trpc_agent_sdk/memory/_sql_memory_service.py index a92e91c65..7183917cd 100644 --- a/trpc_agent_sdk/memory/_sql_memory_service.py +++ b/trpc_agent_sdk/memory/_sql_memory_service.py @@ -191,19 +191,30 @@ def __init__(self, @override async def store_session(self, session: Session, agent_context: Optional[AgentContext] = None) -> None: - """Store a session in the memory. + """Replace the SQL memory rows with the session's complete snapshot. - Only stores events that are not expired based on event_ttl_seconds. + Treat the supplied session as the complete memory snapshot for that + session. Existing rows that are no longer present (for example after + session summarization) are removed in the same transaction. + + 将传入 Session 视为该会话的完整 Memory 快照。摘要压缩等操作移除的旧事件 + 会在同一事务中从 SQL 删除,从而与 InMemory/Redis 的替换语义保持一致。 """ async with self._sql_storage.create_db_session() as sql_session: is_exist = False + # Track only events that produce storable memory content; all other + # existing rows for this session become stale snapshot members. + # 仅记录可生成 Memory 内容的事件;该 Session 的其余已有行均属于过期快照。 + current_event_ids: set[str] = set() for event in session.events: if not event.content or not event.content.parts: continue content = sanitize_content_json(event.content.model_dump(exclude_none=True, mode="json")) if content: is_exist = True - # Check if the event already exists + current_event_ids.add(event.id) + # Upsert current snapshot members by their scoped key. + # 使用包含 save_key/session_id 的作用域键更新或插入当前快照成员。 event_key = SqlKey(key=(event.id, session.save_key, session.id), storage_cls=MemStorageEvent) storage_event: Optional[MemStorageEvent] = await self._sql_storage.get(sql_session, event_key) if storage_event: @@ -211,7 +222,36 @@ async def store_session(self, session: Session, agent_context: Optional[AgentCon else: await self._sql_storage.add(sql_session, MemStorageEvent.from_event(session, event)) - if is_exist: + # Read all persisted members in the same session scope, then derive + # the rows absent from the new complete snapshot. + # 查询同一 Session 作用域的全部持久化成员,再计算新完整快照中缺失的旧行。 + filters = [ + MemStorageEvent.save_key == session.save_key, + MemStorageEvent.session_id == session.id, + ] + session_event_key = SqlKey(key=(session.save_key, session.id), storage_cls=MemStorageEvent) + existing_events: List[MemStorageEvent] = await self._sql_storage.query( + sql_session, + session_event_key, + SqlCondition(filters=filters), + ) + stale_event_ids = [stored.id for stored in existing_events if stored.id not in current_event_ids] + if stale_event_ids: + # Delete only the calculated IDs within the same save/session + # scope; the scoped filters prevent cross-session cleanup. + # 仅在同一 save_key/session_id 范围内删除计算出的 stale IDs, + # 防止清理操作影响其他 Session。 + delete_conditions = SqlCondition(filters=[ + MemStorageEvent.save_key == session.save_key, + MemStorageEvent.session_id == session.id, + MemStorageEvent.id.in_(stale_event_ids), + ]) + await self._sql_storage.delete(sql_session, session_event_key, delete_conditions) + + # Commit both upserts and stale deletion together; an empty-to-empty + # snapshot is a true no-op and does not open an unnecessary commit. + # upsert 与 stale 删除一并提交;空快照替换空存储时无需额外 commit。 + if is_exist or stale_event_ids: await self._sql_storage.commit(sql_session) @override diff --git a/trpc_agent_sdk/sessions/__init__.py b/trpc_agent_sdk/sessions/__init__.py index 7f18a643c..c49a51a86 100644 --- a/trpc_agent_sdk/sessions/__init__.py +++ b/trpc_agent_sdk/sessions/__init__.py @@ -24,6 +24,10 @@ from ._session import Session from ._session_summarizer import SessionSummarizer from ._session_summarizer import SessionSummary +# Public replay API for structured summary metadata and persistent recovery. +# 面向回放的一组公开 API,用于结构化摘要元数据及持久化恢复。 +from ._session_summarizer import SESSION_SUMMARY_METADATA_KEY +from ._session_summarizer import session_summary_from_event from ._sql_session_service import SessionStorageBase from ._sql_session_service import SessionStorageEvent from ._sql_session_service import SqlSessionService @@ -62,6 +66,8 @@ "Session", "SessionSummarizer", "SessionSummary", + "SESSION_SUMMARY_METADATA_KEY", + "session_summary_from_event", "SessionStorageBase", "SessionStorageEvent", "SqlSessionService", diff --git a/trpc_agent_sdk/sessions/_base_session_service.py b/trpc_agent_sdk/sessions/_base_session_service.py index 979523f46..32246a720 100644 --- a/trpc_agent_sdk/sessions/_base_session_service.py +++ b/trpc_agent_sdk/sessions/_base_session_service.py @@ -84,25 +84,54 @@ def set_summarizer_manager(self, summarizer_manager: SummarizerSessionManager, f @override async def append_event(self, session: Session, event: Event) -> Event: - """Appends an event to a session object.""" + """Append an event to the caller-owned session object. + + 将事件追加到调用方持有的 Session 对象;具体后端会在此基础上完成持久化。 + """ if event.partial: return event - event, _ = self._append_event_to_session(session, event) + event, _, _ = self._append_event_to_session(session, event) return event - def _append_event_to_session(self, session: Session, event: Event) -> tuple[Event, list[Event]]: - """Append an event to the in-memory session and return filtered events.""" + def _append_event_to_session(self, session: Session, event: Event) -> tuple[Event, list[Event], bool]: + """Append an event and return the event, filtered events, and append status. + + Event IDs are idempotency keys within a session. A retry with an ID + already present in either the active or historical window is a no-op. + + 将 Event ID 作为 Session 内的幂等键。如果重试事件已存在于活动事件或 + 历史事件中,则不重复更新 state 或追加事件。第三个返回值表示本次是否 + 确实修改了调用方 Session,供具体后端区分重复请求和失败后的补偿写入。 + """ + # Check both event windows because a previously persisted event may + # already have been moved out of the active window by TTL/count limits. + # 同时检查活动与历史窗口,因为已持久化事件可能已因 TTL/数量限制被移入历史。 + existing_event = next( + (stored for stored in [*session.events, *session.historical_events] if stored.id == event.id), + None, + ) + if existing_event is not None: + # Return the stored object without applying state again. Reapplying + # a duplicate event could corrupt non-idempotent state transitions. + # 返回已存在对象且不重复应用 state,避免非幂等状态变更被执行两次。 + return existing_event, [], False + # Apply temp-scoped state to in-memory session before trimming event delta, # so same-invocation consumers can still read temp values. + # 先把 temp state 应用到内存 Session,再从待持久化 delta 中移除, + # 使本次调用可读取临时值但后端不会保存它。 self._apply_temp_state(session, event) event = self._trim_temp_delta_state(event) self.__update_session_state(session, event) + # Add once, then return any events evicted by TTL/count rules so + # persistent backends can mirror the same active/historical windows. + # 仅追加一次,并返回被 TTL/数量规则淘汰的事件,供持久化后端同步窗口。 filtered_events = session._add_event_and_get_filtered_events( event, event_ttl_seconds=self._session_config.event_ttl_seconds, max_events=self._session_config.max_events, store_filtered_events=self._session_config.store_historical_events) - return event, filtered_events + return event, filtered_events, True def _apply_temp_state(self, session: Session, event: Event) -> None: """Apply temp-scoped state delta to in-memory session state only. diff --git a/trpc_agent_sdk/sessions/_in_memory_session_service.py b/trpc_agent_sdk/sessions/_in_memory_session_service.py index 567a52d16..e476e26eb 100644 --- a/trpc_agent_sdk/sessions/_in_memory_session_service.py +++ b/trpc_agent_sdk/sessions/_in_memory_session_service.py @@ -219,11 +219,18 @@ async def delete_session(self, *, app_name: str, user_id: str, session_id: str) @override async def append_event(self, session: Session, event: Event) -> Event: - # Update the in-memory session. + """Append an event idempotently to the caller and stored sessions. + + 以 Event ID 为幂等键,同时更新调用方 Session 与内存存储副本;如果前次 + 调用只修改了调用方对象却未完成存储写入,本次重试会补齐存储数据。 + """ + # Partial streaming events are transient and must not enter storage. + # 流式 partial 事件属于临时结果,不应进入 Session 存储。 if event.partial: return event - # Update the storage session + # Resolve the exact storage scope before mutating either representation. + # 在修改任一 Session 表示前,先确定准确的存储作用域。 app_name = session.app_name user_id = session.user_id session_id = session.id @@ -241,31 +248,53 @@ def _warning(message: str) -> None: _warning(f"session_id {session_id} not in sessions[app_name][user_id]") return event - await super().append_event(session=session, event=event) + # Mutate the caller-owned session first. ``appended`` is false when + # this ID is already present locally, including after a failed write. + # 先更新调用方 Session;若该 ID 已在本地(包括上次写入失败),appended 为 false。 + event, _, appended = self._append_event_to_session(session, event) - # Get session with TTL wrapper + # Fetch the independent storage copy through its TTL-aware accessor. + # 通过带 TTL 检查的读取方法取得与调用方隔离的存储副本。 storage_session = self._get_session(app_name, user_id, session_id) if storage_session is None: _warning("session not found") return event + # A duplicate is complete only when storage also has the ID. If the + # caller has it but storage does not, continue below as failure recovery. + # 只有存储中也存在该 ID 才算完整重复;若仅调用方存在,则继续执行补偿写入。 + if not appended and any(stored.id == event.id + for stored in [*storage_session.events, *storage_session.historical_events]): + return event - # Add event to storage session - storage_session.events.append(event) - - # Extract and apply state changes to appropriate storage buckets + # This is either the normal first write or recovery of a write that + # stopped after local mutation. Mirror the complete caller windows so + # TTL/max-events filtering and historical retention remain consistent. + # 此处处理首次写入和“本地已改、存储未写”的补偿,并镜像完整窗口,确保 + # TTL/max_events 过滤及历史事件保留语义一致。 + storage_session.events = list(session.events) + storage_session.historical_events = (list(session.historical_events) + if self._session_config.store_historical_events else []) + storage_session.last_update_time = session.last_update_time + + # Split the delta by scope so app/user/session state is stored in the + # same buckets used by normal reads. + # 按作用域拆分 delta,将 app/user/session state 写入读取逻辑对应的存储桶。 if event.actions and event.actions.state_delta: state_delta = extract_state_delta(event.actions.state_delta) - # Update app state + # Update application-scoped state / 更新应用级 state。 if state_delta.app_state_delta: self._update_app_state(app_name, state_delta.app_state_delta) - # Update user state + # Update user-scoped state / 更新用户级 state。 if state_delta.user_state_delta: self._update_user_state(app_name, user_id, state_delta.user_state_delta) + # Update session-scoped state / 更新会话级 state。 if state_delta.session_state: storage_session.state.update(state_delta.session_state) + # Keep derived conversation metadata aligned with the caller snapshot. + # 让派生的对话计数与调用方快照保持一致。 storage_session.conversation_count = session.conversation_count return event @@ -461,24 +490,37 @@ def _update_user_state(self, app_name: str, user_id: str, data: dict[str, Any]) return self._user_state[app_name][user_id].update(data) def _set_session(self, app_name: str, user_id: str, session_id: str, session: Session) -> None: - """Set a session to the in-memory storage. + """Store a deep-isolated session in the in-memory backend. + + 将 Session 深拷贝后写入内存后端,避免调用方与存储共享可变的事件列表或 state。 Args: - app_name: Application name - user_id: User ID - session_id: Session ID - session: Session to set + app_name: Application name / 应用名称 + user_id: User ID / 用户 ID + session_id: Session ID / 会话 ID + session: Session to set / 待写入的会话 """ - # Initialize storage structures + # Initialize the nested app/user/session storage hierarchy on demand. + # 按需初始化 app/user/session 三层存储结构。 if app_name not in self._sessions: self._sessions[app_name] = {} if user_id not in self._sessions[app_name]: self._sessions[app_name][user_id] = {} + # Never share mutable event/state containers with the caller. After a + # summary update, a shared list would expose the next caller-side append + # to storage before append_event runs, so persistence would append it twice. + # 禁止存储与调用方共享 events/state 等可变容器。摘要更新后若列表共享,下一条 + # 调用方事件会在 append_event 持久化前提前出现在存储中,继而被重复追加。 + session = session.model_copy(deep=True) if not self._session_config.store_historical_events: - session = session.model_copy(update={"historical_events": []}) + # Enforce backend configuration on the copied object without + # mutating the caller-owned session. + # 仅清理存储副本中的历史事件,遵循配置且不修改调用方对象。 + session.historical_events = [] - # Store session with TTL + # Wrap the isolated copy with TTL metadata before publishing it. + # 为隔离副本附加 TTL 元数据后再写入存储。 session_with_ttl = SessionWithTTL(session=session, ttl=self._session_config.ttl) session_with_ttl.update(session) self._sessions[app_name][user_id][session_id] = session_with_ttl diff --git a/trpc_agent_sdk/sessions/_redis_session_service.py b/trpc_agent_sdk/sessions/_redis_session_service.py index 8bec47af1..4a5166b81 100644 --- a/trpc_agent_sdk/sessions/_redis_session_service.py +++ b/trpc_agent_sdk/sessions/_redis_session_service.py @@ -54,6 +54,35 @@ def _session_key_prefix(app_name: str, user_id: Optional[str] = None) -> str: return f"session:{app_name}:{user_id}:*" +def _decode_state_hash(raw_state: Any) -> dict[str, Any]: + """Decode Redis Hash keys and values at the Session state boundary. + + 在 Session state 边界解码 Redis Hash 的键和值;仅处理 Redis 返回的 + ``bytes``,不改变 mock、Cluster 或显式解码客户端返回的原生 Python 值。 + """ + if not isinstance(raw_state, dict): + return {} + + decoded: dict[str, Any] = {} + for raw_key, raw_value in raw_state.items(): + # Redis state keys are textual. Decode raw responses so a later str + # state_delta overwrites the same field instead of creating bytes/str + # duplicates in the intermediate dictionary. + # Redis state 键为文本;解码原始响应,避免后续 str 类型 delta 与 bytes + # 类型旧键并存,导致同一字段被当成两个 HSET 项。 + key = raw_key.decode("utf-8") if isinstance(raw_key, bytes) else raw_key + value = raw_value + if isinstance(raw_value, bytes): + try: + value = raw_value.decode("utf-8") + except UnicodeDecodeError: + # Preserve non-text values rather than corrupting their bytes. + # 非文本值保持原始 bytes,避免错误解码造成数据损坏。 + value = raw_value + decoded[str(key)] = value + return decoded + + class RedisSessionService(BaseSessionService): """A Redis implementation of the session service. @@ -189,14 +218,23 @@ async def delete_session(self, *, app_name: str, user_id: str, session_id: str) @override async def append_event(self, session: Session, event: Event) -> Event: - # Skip partial events + """Append an event idempotently and persist the session to Redis. + + 以 Event ID 保证追加幂等,并将调用方 Session 快照保存到 Redis;若前次 + 仅完成本地修改,本次重试会检测 Redis 缺失并继续补写。 + """ + # Partial streaming events are not durable session history. + # 流式 partial 事件不属于可持久化的会话历史。 if event.partial: return event - # Update the in-memory session - await super().append_event(session=session, event=event) + # Update the caller copy first and retain whether this invocation + # actually appended the ID locally. + # 先更新调用方副本,并记录本次是否真正向本地追加了该 Event ID。 + event, _, appended = self._append_event_to_session(session, event) - # Update storage + # Resolve the Redis key scope from the caller-owned session identity. + # 根据调用方 Session 标识确定 Redis key 的作用域。 app_name = session.app_name user_id = session.user_id session_id = session.id @@ -207,27 +245,39 @@ def _warning(message: str) -> None: async with self._redis_storage.create_db_session() as redis_session: redis_session_key = session_key(app_name, user_id, session_id) - # Get storage session + # Read the durable snapshot before deciding whether a local + # duplicate can safely short-circuit. + # 在判断本地重复能否直接返回前,先读取 Redis 中的持久化快照。 storage_session = await self._get_session(redis_session, redis_session_key) if not storage_session: _warning("session not found in Redis") return event - # Extract and apply state changes to appropriate storage buckets + # Local and Redis copies both contain the ID: the retry is complete. + # If only the caller has it, continue to repair the missing Redis write. + # 本地与 Redis 都有该 ID 才直接返回;仅本地存在时继续补偿 Redis 写入。 + if not appended and any(stored.id == event.id + for stored in [*storage_session.events, *storage_session.historical_events]): + return event + # Split the delta into the same app/user/session scopes used by reads. + # 按读取语义拆分 delta,并分别写入 app/user/session 作用域。 if event.actions and event.actions.state_delta: state_delta = extract_state_delta(event.actions.state_delta) - # Update app state and refresh TTL + # Update app state and refresh TTL / 更新应用 state 并刷新 TTL。 if state_delta.app_state_delta: await self._update_app_state(redis_session, app_name, state_delta.app_state_delta) - # Update user state and refresh TTL + # Update user state and refresh TTL / 更新用户 state 并刷新 TTL。 if state_delta.user_state_delta: await self._update_user_state(redis_session, app_name, user_id, state_delta.user_state_delta) - # Update session state + # Update session state / 更新会话级 state。 if state_delta.session_state: storage_session.state.update(state_delta.session_state) + # Persist the complete caller-side windows so normal writes and + # failure-recovery retries converge to the same Redis snapshot. + # 写入调用方的完整事件窗口,使首次写入与失败重试最终得到同一 Redis 快照。 storage_session.events = session.events storage_session.historical_events = session.historical_events storage_session.conversation_count = session.conversation_count @@ -272,11 +322,11 @@ async def _update_app_state(self, redis_session: RedisSession, app_name: str, key = app_state_key(app_name) command = RedisCommand(method='hgetall', args=(key, )) - app_state: dict[str, Any] = await self._redis_storage.execute_command(redis_session, command) + app_state = _decode_state_hash(await self._redis_storage.execute_command(redis_session, command)) if app_state: app_state.update(state_delta) else: - app_state = state_delta + app_state = dict(state_delta) if not app_state: return {} @@ -285,13 +335,14 @@ async def _update_app_state(self, redis_session: RedisSession, app_name: str, await self._refresh_ttl(redis_session, key) return app_state - # Use HSET with TTL if TTL is configured, otherwise use HSET - args = [key] - for k, v in app_state.items(): - args.extend([k, v]) - + # redis-py HSET accepts multiple fields through ``mapping``. Flattened + # positional pairs bind the fourth argument as ``mapping`` and fail on + # redis-py 8 when more than one field is present. + # redis-py 的 HSET 通过 ``mapping`` 接收多字段;展开的位置参数在字段 + # 超过一个时会把第 4 个参数绑定为 mapping,并在 redis-py 8 中报错。 command = RedisCommand(method='hset', - args=tuple(args), + args=(key, ), + kwargs={"mapping": app_state}, expire=RedisExpire(key=key, ttl=self._session_config.ttl)) await self._redis_storage.execute_command(redis_session, command) @@ -312,11 +363,11 @@ async def _update_user_state(self, redis_session: RedisSession, app_name: str, u key = user_state_key(app_name, user_id) command = RedisCommand(method='hgetall', args=(key, )) - user_state: dict[str, Any] = await self._redis_storage.execute_command(redis_session, command) + user_state = _decode_state_hash(await self._redis_storage.execute_command(redis_session, command)) if user_state: user_state.update(state_delta) else: - user_state = state_delta + user_state = dict(state_delta) if not user_state: return {} @@ -325,13 +376,11 @@ async def _update_user_state(self, redis_session: RedisSession, app_name: str, u await self._refresh_ttl(redis_session, key) return user_state - # Use HSET with TTL if TTL is configured, otherwise use HSET - args = [key] - for k, v in user_state.items(): - args.extend([k, v]) - + # Keep user-scoped state on the same redis-py mapping path as app state. + # 用户级 state 与应用级 state 使用相同的 redis-py mapping 写入语义。 command = RedisCommand(method='hset', - args=tuple(args), + args=(key, ), + kwargs={"mapping": user_state}, expire=RedisExpire(key=key, ttl=self._session_config.ttl)) await self._redis_storage.execute_command(redis_session, command) @@ -370,7 +419,7 @@ async def _get_app_state(self, redis_session: RedisSession, app_name: str) -> di """ key = app_state_key(app_name) command = RedisCommand(method='hgetall', args=(key, )) - app_state = await self._redis_storage.execute_command(redis_session, command) + app_state = _decode_state_hash(await self._redis_storage.execute_command(redis_session, command)) if app_state: await self._refresh_ttl(redis_session, key) @@ -391,7 +440,7 @@ async def _get_user_state(self, redis_session: RedisSession, app_name: str, user """ key = user_state_key(app_name, user_id) command = RedisCommand(method='hgetall', args=(key, )) - user_state = await self._redis_storage.execute_command(redis_session, command) + user_state = _decode_state_hash(await self._redis_storage.execute_command(redis_session, command)) if user_state: await self._refresh_ttl(redis_session, key) return user_state or {} diff --git a/trpc_agent_sdk/sessions/_session_summarizer.py b/trpc_agent_sdk/sessions/_session_summarizer.py index da7171107..7b2ed2298 100644 --- a/trpc_agent_sdk/sessions/_session_summarizer.py +++ b/trpc_agent_sdk/sessions/_session_summarizer.py @@ -67,56 +67,166 @@ Summary:""") +# Stable keys shared by summary persistence and replay recovery. +# 摘要持久化与回放恢复共同使用的稳定键值。 +SESSION_SUMMARY_METADATA_KEY = "session_summary" +SUMMARY_TEXT_PREFIX = "Previous conversation summary:" + +# Keep the ordering anchor well above the microsecond storage quantum while +# remaining negligible for TTL semantics and the real timestamp in metadata. +# 排序锚点间隔需明显大于数据库的微秒精度,同时不能影响 TTL;真实更新时间仍保存在元数据中。 +_SUMMARY_EVENT_ORDERING_GAP_SECONDS = 0.001 + class SessionSummary(BaseModel): """Represents a summary of a session's conversation history. This class encapsulates the summary information including the summary text, - metadata about the summarization process, and references to the original events. + metadata about the summarization process, and the versioned replacement + chain needed to recover the latest persisted summary. + + 表示会话历史摘要,包含摘要正文、压缩过程元数据,以及从持久化事件恢复 + 最新摘要所需的版本和覆盖链。 """ model_config = ConfigDict(arbitrary_types_allowed=True) - """The pydantic model config.""" + """The pydantic model config. / Pydantic 模型配置。""" + summary_id: str = "" + """The persisted summary event ID. / 持久化摘要事件的 ID。""" session_id: str - """The ID of the session being summarized.""" + """The owning session ID. / 摘要所属会话的 ID。""" summary_text: str - """The summary text.""" + """The normalized summary text. / 规范化后的摘要正文。""" + version: int = 1 + """A session-scoped monotonic version. / 会话内单调递增的摘要版本。""" + replaces_summary_id: Optional[str] = None + """The replaced summary event ID, if any. / 被当前版本覆盖的上一摘要事件 ID。""" original_event_count: int - """The number of events before summarization.""" + """The event count before compression. / 压缩前的事件数量。""" compressed_event_count: int - """The number of events after summarization.""" + """The active event count after compression. / 压缩后的活跃事件数量。""" summary_timestamp: float - """The timestamp when the summary was created.""" + """The real summary update time. / 摘要实际生成或更新的时间。""" metadata: Dict[str, Any] = Field(default_factory=dict) - """Additional metadata about the summarization.""" + """Additional persisted metadata. / 额外的持久化摘要元数据。""" def get_compression_ratio(self) -> float: """Get the compression ratio achieved by summarization. + 获取摘要压缩比例;原始事件为空时返回零,避免除零。 + Returns: - Compression ratio as a percentage (0-100) + Compression ratio as a percentage (0-100). + 以百分比表示的压缩率(0-100)。 """ if self.original_event_count == 0: return 0.0 return (self.original_event_count - self.compressed_event_count) / self.original_event_count * 100 def to_dict(self) -> Dict[str, Any]: - """Convert summary to dictionary representation. + """Serialize replay-relevant summary fields to a dictionary. + + 将回放所需的摘要 ID、版本、覆盖关系及实际更新时间序列化为字典; + 模型名来自持久化 metadata,而非瞬时模型对象。 Returns: - Dictionary representation of the summary + Dictionary representation of the summary. + 摘要的字典表示。 """ return { + "summary_id": self.summary_id, "session_id": self.session_id, "summary_text": self.summary_text, + "version": self.version, + "replaces_summary_id": self.replaces_summary_id, "original_event_count": self.original_event_count, "compressed_event_count": self.compressed_event_count, "summary_timestamp": self.summary_timestamp, - "model_name": self.model.name, + "model_name": self.metadata.get("model_name"), "compression_ratio": self.get_compression_ratio(), "metadata": self.metadata, } +def session_summary_from_event(event: Event, fallback_session_id: Optional[str] = None) -> Optional[SessionSummary]: + """Rebuild a versioned summary from a persisted summary event. + + New summary events carry structured metadata. Legacy events are still + readable by deriving the text, ID, version, and timestamp from the event, + while session ownership must always be available and valid. + + 从持久化摘要事件恢复带版本的摘要。新事件优先读取结构化元数据;旧事件 + 则从事件正文、ID、版本和时间戳回退恢复,但必须能确定有效的会话归属。 + + Args: + event: Persisted event that may be a summary anchor. + 可能作为摘要锚点的持久化事件。 + fallback_session_id: Owner used only when legacy metadata has no + session ID. 仅在旧元数据缺少 session ID 时使用的归属回退值。 + + Returns: + The reconstructed summary, or ``None`` for a non-summary event or + invalid ownership. 恢复后的摘要;非摘要事件或归属无效时返回 ``None``。 + """ + # Reject ordinary events before interpreting their content as summary data. + # 先拒绝普通事件,避免把普通文本误解析为摘要数据。 + if not event.is_summary_event(): + return None + + # Structured metadata is authoritative; an empty mapping activates the + # legacy recovery path without breaking previously stored sessions. + # 结构化元数据是权威来源;空映射会进入兼容旧存量会话的恢复路径。 + event_metadata = event.custom_metadata or {} + raw_metadata = event_metadata.get(SESSION_SUMMARY_METADATA_KEY) + metadata = raw_metadata if isinstance(raw_metadata, dict) else {} + + # Legacy events stored only the prefixed text in Event.content. + # 旧事件仅在 Event.content 中保存带前缀的摘要正文。 + summary_text = metadata.get("summary_text") + if not isinstance(summary_text, str): + summary_text = event.get_text() + if summary_text.startswith(SUMMARY_TEXT_PREFIX): + summary_text = summary_text[len(SUMMARY_TEXT_PREFIX):].lstrip() + + # Ownership is strict: the caller's fallback is for legacy compatibility, + # not permission to override an explicit metadata owner. + # 会话归属必须严格校验:回退值只兼容旧数据,不能覆盖元数据中的显式归属。 + session_id = metadata.get("session_id", fallback_session_id) + if not isinstance(session_id, str) or not session_id: + return None + + # Prefer replay metadata while retaining safe defaults for legacy events. + # 优先使用回放元数据,同时为旧事件保留安全的字段回退值。 + version = metadata.get("version", event.version or 1) + original_event_count = metadata.get("original_event_count", 0) + compressed_event_count = metadata.get("compressed_event_count", 0) + summary_timestamp = metadata.get("summary_timestamp", event.timestamp) + replaces_summary_id = metadata.get("replaces_summary_id") + + # Persisted metadata may come from older versions or external storage. + # Reject malformed numeric anchors instead of breaking session recovery. + # 持久化元数据可能来自旧版本或外部存储;遇到畸形数字锚点时返回 None, + # 避免单条坏数据中断整个 Session 的摘要恢复。 + try: + version_int = int(version) + original_event_count_int = int(original_event_count) + compressed_event_count_int = int(compressed_event_count) + summary_timestamp_float = float(summary_timestamp) + except (TypeError, ValueError, OverflowError): + return None + + return SessionSummary( + summary_id=event.id, + session_id=session_id, + summary_text=summary_text, + version=version_int, + replaces_summary_id=replaces_summary_id if isinstance(replaces_summary_id, str) else None, + original_event_count=original_event_count_int, + compressed_event_count=compressed_event_count_int, + summary_timestamp=summary_timestamp_float, + metadata=metadata, + ) + + class SessionSummarizer: """Summarizes conversation history to reduce memory usage. @@ -363,19 +473,30 @@ async def create_session_summary_by_events( ctx: InvocationContext | None = None, historical_events: Optional[List[Event]] = None, store_historical_events: bool = False) -> tuple[Optional[str], List[Event]]: - """Compress a session by summarizing old events. + """Compress events and persist a versioned summary anchor. + + The active window becomes ``summary + retained events``. Each new + summary increments the prior summary version and records its predecessor + so a backend reload can validate the replacement chain. + + 压缩事件并生成可持久化的版本化摘要锚点。活跃窗口会变为“摘要 + + 保留事件”;每次更新递增上一版版本并记录被覆盖摘要,从而让后端重载 + 时能够校验覆盖链。 Args: - events: The events to compress - session_id: The session ID - keep_recent_count: Number of recent events to keep after compression - ctx: The invocation context - historical_events: Optional list to receive raw events replaced by the summary - store_historical_events: Whether to keep raw historical events + events: The active events to compress. 要压缩的活跃事件。 + session_id: The owning session ID. 摘要所属会话 ID。 + keep_recent_count: Recent events retained after compression. + 压缩后保留的最近事件数。 + ctx: The invocation context. 调用上下文。 + historical_events: Optional destination for replaced raw events. + 可选的被替换原始事件保存列表。 + store_historical_events: Whether to retain replaced raw events. + 是否保留被摘要替换的原始事件。 Returns: - Summary text if successful, None otherwise - Events after compression + A tuple of summary text (or ``None``) and the compressed active + events. 摘要正文(失败时为 ``None``)和压缩后的活跃事件。 """ try: original_count = len(events) @@ -388,23 +509,66 @@ async def create_session_summary_by_events( summary_text = await self._compress_session_to_summary(events_for_summary, session_id, ctx) if summary_text: - # Create summary event + # Version is session-local and monotonic; the predecessor ID + # makes overwrite/replacement errors observable during replay. + # 版本在会话内单调递增,上一摘要 ID 让覆盖错误在回放中可检测。 + previous_summary_event = next((event for event in reversed(events) if event.is_summary_event()), None) + summary_version = (previous_summary_event.version or 1) + 1 if previous_summary_event else 1 + summary_timestamp = time.time() + compressed_count = 1 + len(events[insert_index:]) + retained_events = events[insert_index:] + # SQL reconstructs the event list by timestamp. The summary is + # an anchor that must precede retained events even though it was + # generated later, so use an ordering timestamp just before the + # first retained event and keep the actual update time in + # structured summary metadata. + # SQL 会按时间戳重建事件顺序。摘要虽然后生成,却必须位于保留 + # 事件之前,因此事件时间用于排序;实际更新时间另存于结构化元数据。 + # A one-microsecond float offset can round to the same SQL + # datetime value, so use a millisecond-scale ordering gap. + # 浮点数减一微秒在转换为 SQL datetime 时可能舍入成相同值, + # 因此使用毫秒级排序间隔。 + summary_event_timestamp = (min(event.timestamp + for event in retained_events) - _SUMMARY_EVENT_ORDERING_GAP_SECONDS + if retained_events else summary_timestamp) + # Persist the summary as a normal event so every session backend + # can reload it without a backend-specific summary table. + # 将摘要作为普通事件持久化,使所有后端无需专用摘要表即可重载。 summary_event = Event(invocation_id="summary", author="system", content=Content( - parts=[Part.from_text(text=f"Previous conversation summary: {summary_text}")], + parts=[Part.from_text(text=f"{SUMMARY_TEXT_PREFIX} {summary_text}")], role="user"), - timestamp=time.time()) + timestamp=summary_event_timestamp, + version=summary_version) summary_event.set_summary_event(True) + # Store semantic text and strict replay metadata separately: + # owner, version, and replacement chain must never be normalized away. + # 正文与严格回放元数据分开保存:归属、版本及覆盖链不得被归一化忽略。 + summary_event.custom_metadata = { + SESSION_SUMMARY_METADATA_KEY: { + "summary_id": summary_event.id, + "session_id": session_id, + "summary_text": summary_text, + "version": summary_version, + "replaces_summary_id": previous_summary_event.id if previous_summary_event else None, + "original_event_count": original_count, + "compressed_event_count": compressed_count, + "summary_timestamp": summary_timestamp, + "model_name": self.model.name if self.model else None, + } + } summarized_events = events[:insert_index] if store_historical_events and historical_events is not None: + # Historical storage preserves raw context replaced by the anchor. + # historical_events 保存被摘要锚点替换的原始上下文。 historical_events.extend(summarized_events) # Keep only the summary and recent active events in the model-facing window. + # 模型可见窗口只保留摘要锚点及最近事件。 events[:] = [summary_event] + events[insert_index:] - compressed_count = len(events) logger.info("Compressed session %s: %s events -> %s events", session_id, original_count, compressed_count) diff --git a/trpc_agent_sdk/sessions/_sql_session_service.py b/trpc_agent_sdk/sessions/_sql_session_service.py index 4333ffeb1..a23550833 100644 --- a/trpc_agent_sdk/sessions/_sql_session_service.py +++ b/trpc_agent_sdk/sessions/_sql_session_service.py @@ -532,10 +532,20 @@ async def delete_session(self, app_name: str, user_id: str, session_id: str) -> @override async def append_event(self, session: Session, event: Event) -> Event: + """Append an event idempotently and persist it in SQL. + + 以 Session 范围内的 Event ID 保证幂等,同时同步 state、活动事件与历史 + 事件;若调用方已有事件但数据库缺失,则将本次调用视为失败重试并补写。 + """ + # Partial streaming events are transient and never persisted. + # 流式 partial 事件是临时结果,不进入持久化存储。 if event.partial: return event - event, filtered_events = self._append_event_to_session(session, event) + # ``appended`` describes only the caller-owned object. SQL must still + # be queried because a previous attempt may have failed before commit. + # appended 仅描述调用方对象;前次调用可能在 commit 前失败,因此仍需查询 SQL。 + event, filtered_events, appended = self._append_event_to_session(session, event) app_name = session.app_name user_id = session.user_id @@ -548,17 +558,34 @@ async def append_event(self, session: Session, event: Event) -> Event: logger.warning("Session %s not found in storage, it will be created", session_id) return event + # Use the full composite identity so equal Event IDs in different + # applications, users, or sessions do not collide. + # 使用完整复合标识,避免不同应用、用户或 Session 中相同 Event ID 互相影响。 + storage_event_key = SqlKey( + key=(event.id, app_name, user_id, session_id), + storage_cls=SessionStorageEvent, + ) + existing_storage_event = await self._sql_storage.get(sql_session, storage_event_key) + # Both caller and SQL contain the event, so this is a completed + # duplicate. If SQL lacks it, continue below to repair a failed write. + # 调用方与 SQL 均存在时才是完整重复;SQL 缺失时继续执行以补偿失败写入。 + if not appended and existing_storage_event is not None: + return event + time_diff = storage_session.update_timestamp_tz - session.last_update_time if time_diff > 1.0: logger.warning( "Session %s is stale (time diff: %ss). Reloading session from database to get latest state.", session_id, time_diff) # The event was already appended to the caller-provided - # session before this reload. If another writer concurrently - # changed the same session, filtered_events may no longer - # describe the exact database window. Full conflict resolution - # would require versioned writes/locking; keep the existing - # best-effort stale-session behavior here. + # session before this reload. Full conflict resolution for + # concurrent writers would require versioned writes or locking; + # keep the existing best-effort stale-session behavior here. + # 事件在刷新前已追加到调用方 Session。并发写入的完整冲突解决需要 + # 版本化写入或锁,因此这里维持原有的尽力刷新语义。 + # ``filtered_events`` may no longer exactly describe the + # database event window after a concurrent change. + # 并发变更后,filtered_events 可能不再精确对应数据库事件窗口。 await self._sql_storage.refresh(sql_session, storage_session) filters = [ SessionStorageEvent.app_name == app_name, SessionStorageEvent.session_id == session_id, @@ -576,8 +603,13 @@ async def append_event(self, session: Session, event: Event) -> Event: session.historical_events = (_events_from_storage(storage_session.historical_events) if self._session_config.store_historical_events else []) + # Mirror derived conversation metadata in the SQL session row. + # 将派生的对话计数同步到 SQL Session 行。 storage_session.conversation_count = session.conversation_count + # Split state updates by scope; app/user values live in dedicated + # rows while session values stay on the session record. + # 按作用域拆分 state:app/user 写入独立记录,session state 保存在会话行。 if event.actions and event.actions.state_delta: state_entry = extract_state_delta(event.actions.state_delta) @@ -592,6 +624,10 @@ async def append_event(self, session: Session, event: Event) -> Event: session_state.update(state_entry.session_state) storage_session.state = session_state # type: ignore + # Keep SQL's active/historical windows aligned with filtering done + # on the caller Session by deleting evicted active rows and storing + # the resulting history snapshot. + # 删除被淘汰的活动事件并保存历史快照,使 SQL 窗口与调用方过滤结果一致。 if filtered_events: filtered_event_ids = [filtered_event.id for filtered_event in filtered_events] storage_session.historical_events = (_events_to_storage( @@ -607,8 +643,15 @@ async def append_event(self, session: Session, event: Event) -> Event: else: filtered_event_ids = [] - if event.id not in filtered_event_ids: + # Do not reinsert an event evicted from the active window, and do + # not insert a row that already exists. A retry with a missing row + # reaches this branch and performs exactly one compensating insert. + # 不把已被活动窗口淘汰的事件重新插回,也不重复插入已有行;数据库缺行的 + # 重试会到达此处并只执行一次补偿插入。 + if event.id not in filtered_event_ids and existing_storage_event is None: await self._sql_storage.add(sql_session, SessionStorageEvent.from_event(session, event)) + # State/window updates and the event insert commit atomically. + # state、事件窗口更新与 Event 插入在同一事务中原子提交。 await self._sql_storage.commit(sql_session) await self._sql_storage.refresh(sql_session, storage_session) diff --git a/trpc_agent_sdk/sessions/_summarizer_manager.py b/trpc_agent_sdk/sessions/_summarizer_manager.py index 2a07f58c0..f925e53f9 100644 --- a/trpc_agent_sdk/sessions/_summarizer_manager.py +++ b/trpc_agent_sdk/sessions/_summarizer_manager.py @@ -40,6 +40,7 @@ from ._session import Session from ._session_summarizer import SessionSummarizer from ._session_summarizer import SessionSummary +from ._session_summarizer import session_summary_from_event class SummarizerSessionManager: @@ -47,7 +48,11 @@ class SummarizerSessionManager: This service extends the basic session service with automatic conversation summarization to reduce memory usage and maintain - context in long conversations. + context in long conversations. Persisted summary events are the recovery + source of truth; the in-process cache is only an acceleration layer. + + 为会话提供自动摘要能力。持久化摘要事件是恢复时的事实来源,进程内缓存 + 仅用于加速读取。 """ def __init__( @@ -95,19 +100,31 @@ async def create_session_summary(self, session: Session, force: bool = False, ctx: InvocationContext = None) -> None: - """Create a session summary and compress the session if needed. + """Create, cache, and persist a summary when compression is needed. + + The cache is rebuilt from the newly persisted summary event whenever + possible so version, replacement chain, owner, and real update time + exactly match backend data. + + 在需要压缩时创建、缓存并持久化摘要。优先从新生成的持久化摘要事件 + 建立缓存,确保版本、覆盖链、归属和实际更新时间与后端数据完全一致。 Args: - session: The session to summarize + session: The session to summarize. 要生成摘要的会话。 + force: Bypass the configured summary threshold. 是否跳过阈值强制摘要。 + ctx: Optional invocation context. 可选调用上下文。 """ is_should_summarize = await self.should_summarize_session(session) or force - # Check if session should be summarized + # Respect the configured threshold unless the caller explicitly forces compression. + # 除非调用方显式强制,否则遵循已配置的摘要阈值。 if is_should_summarize: logger.debug("Summarizing session %s", session.id) # Compress the session so the active events list contains only # model-visible summary/recent events. Raw events are retained only # when the session service config requests it. + # 压缩后活跃窗口只包含模型可见的摘要和最近事件;仅在后端配置要求时 + # 才把被替换的原始事件保存到 historical_events。 original_event_count = len(session.events) base_config = getattr(self._base_service, "session_config", None) store_historical_events = getattr(base_config, "store_historical_events", False) @@ -122,36 +139,84 @@ async def create_session_summary(self, self._summarizer_cache[app_name] = {} if user_id not in self._summarizer_cache[app_name]: self._summarizer_cache[app_name][user_id] = {} - self._summarizer_cache[app_name][user_id][session.id] = SessionSummary( + # Rehydrate from the event rather than duplicating metadata + # construction, keeping cache and persistent replay semantics aligned. + # 从事件反序列化而非重复拼装元数据,保证缓存与持久化回放语义一致。 + summary_event = next((event for event in reversed(session.events) if event.is_summary_event()), None) + persisted_summary = (session_summary_from_event(summary_event, session.id) + if summary_event is not None else None) + # The fallback supports custom/legacy summarizers that return + # text without emitting a structured summary event. + # 回退对象兼容只返回文本、未生成结构化摘要事件的自定义或旧摘要器。 + self._summarizer_cache[app_name][user_id][session.id] = persisted_summary or SessionSummary( session_id=session.id, summary_text=summary_text, original_event_count=original_event_count, compressed_event_count=len(session.events), summary_timestamp=time.time(), ) - # Update the stored session + # Persist the compressed window and summary event as one session snapshot. + # 将压缩后的窗口和摘要事件作为同一份会话快照持久化。 if self._base_service: await self._base_service.update_session(session) async def get_session_summary(self, session: Session) -> Optional[SessionSummary]: - """Get a summary of a session. + """Get a valid summary from cache or recover it from persisted events. + + Cache hits and recovered summaries must belong to the requested session. + On cache miss, the highest ``(version, timestamp)`` summary event is + selected and cached, making process restarts transparent. + + 从缓存获取有效摘要,或从持久化事件恢复。缓存值与恢复值都必须归属于 + 当前会话;缓存未命中时选择 ``(version, timestamp)`` 最大的摘要事件并 + 回填缓存,使进程重启不导致摘要丢失。 Args: - session: The session to summarize + session: The session whose summary is requested. 要读取摘要的会话。 Returns: - SessionSummary if successful, None otherwise + A valid session-owned summary, or ``None``. + 归属于当前会话的有效摘要;不存在或校验失败时返回 ``None``。 """ - if not self._summarizer or not self._summarizer_cache: + if not self._summarizer: return None app_name = session.app_name user_id = session.user_id - if app_name not in self._summarizer_cache or user_id not in self._summarizer_cache[ - app_name] or session.id not in self._summarizer_cache[app_name][user_id]: + cached = self._summarizer_cache.get(app_name, {}).get(user_id, {}).get(session.id) + if cached is not None: + # Never trust a cache key alone; verify ownership stored in the value. + # 不能只信任缓存键,还必须校验缓存值中记录的会话归属。 + if cached.session_id != session.id: + logger.warning( + "Ignoring cached summary %s with invalid session ownership for %s", + cached.summary_id, + session.id, + ) + return None + return cached + + # Recover after cache loss/restart from summary anchors stored with the session. + # 缓存丢失或进程重启后,从随会话持久化的摘要锚点恢复。 + summary_events = [event for event in session.events if event.is_summary_event()] + if not summary_events: + return None + # Version defines replacement order; timestamp breaks ties for legacy or + # malformed data that reused a version. + # 版本决定覆盖先后;时间戳为旧数据或错误复用版本的情况提供稳定决胜规则。 + summary_event = max(summary_events, key=lambda event: (event.version, event.timestamp)) + summary = session_summary_from_event(summary_event, session.id) + # Explicit metadata ownership must match even when a legacy fallback was supplied. + # 即使提供了旧数据回退值,元数据中的显式归属仍必须与当前会话一致。 + if summary is None or summary.session_id != session.id: + logger.warning("Ignoring summary event %s with invalid session ownership for %s", summary_event.id, + session.id) return None - return self._summarizer_cache[app_name][user_id][session.id] + # Repopulate the acceleration cache with the fully reconstructed metadata. + # 使用完整恢复的元数据回填加速缓存。 + self._summarizer_cache.setdefault(app_name, {}).setdefault(user_id, {})[session.id] = summary + return summary def get_summarizer_metadata(self) -> Dict[str, Any]: """Get metadata about the summarizer configuration.