Skip to content

Commit 12a0398

Browse files
committed
tests: add replay engine and E2E consistency tests
Implement ReplayEngine that executes replay cases against session and memory backends, with support for: - Building Events from replay ops (text, function calls, responses) - Summary injection into SummarizerSessionManager cache - Memory store/search integration - Duplicate append error recovery simulation - Anomaly injection for detection verification Add test_replay_consistency.py with 11 E2E tests: - 10 parametrized tests running all replay cases through InMemory vs SQL (sqlite) backend pair - 1 test generating the session_memory_summary_diff_report.json All 87 tests pass (24 normalizer + 30 comparator + 22 report + 11 E2E). Diff report shows 0% false positive rate on normal cases and 100% detection on injected anomaly cases. Fix false-positive-rate calculation to exclude anomaly cases where diffs are expected. Updates #89 RELEASE NOTES: NONE
1 parent 1e68493 commit 12a0398

6 files changed

Lines changed: 530 additions & 2 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#
2+
# Tencent is pleased to support the open source community by making trpc-agent-python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# trpc-agent-python is licensed under the Apache License Version 2.0.
7+
#
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#
2+
# Tencent is pleased to support the open source community by making trpc-agent-python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# trpc-agent-python is licensed under the Apache License Version 2.0.
7+
#
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
#
2+
# Tencent is pleased to support the open source community by making trpc-agent-python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# trpc-agent-python is licensed under the Apache License Version 2.0.
7+
#
8+
"""Replay engine for executing replay cases against session and memory backends."""
9+
10+
from __future__ import annotations
11+
12+
from typing import Optional
13+
14+
from pydantic import BaseModel
15+
from pydantic import Field
16+
17+
from trpc_agent_sdk.abc import MemoryServiceABC
18+
from trpc_agent_sdk.abc import SessionServiceABC
19+
from trpc_agent_sdk.events import Event
20+
from trpc_agent_sdk.sessions import Session
21+
from trpc_agent_sdk.sessions._session_summarizer import SessionSummary
22+
from trpc_agent_sdk.sessions._summarizer_manager import SummarizerSessionManager
23+
from trpc_agent_sdk.types import Content
24+
from trpc_agent_sdk.types import EventActions
25+
from trpc_agent_sdk.types import FunctionCall
26+
from trpc_agent_sdk.types import FunctionResponse
27+
from trpc_agent_sdk.types import Part
28+
29+
from ..replay_cases._base import ReplayCase
30+
from ..replay_cases._base import ReplayOp
31+
32+
33+
class BackendResult(BaseModel):
34+
"""Raw result collected from replaying a case against one backend."""
35+
36+
events: list[dict] = Field(default_factory=list)
37+
"""Serialized event dicts from ``Event.model_dump()``."""
38+
39+
state: dict = Field(default_factory=dict)
40+
"""Session state after replay."""
41+
42+
summaries: list[dict] = Field(default_factory=list)
43+
"""Serialized ``SessionSummary`` dicts."""
44+
45+
memory_entries: list[dict] = Field(default_factory=list)
46+
"""Serialized ``MemoryEntry`` dicts from search results."""
47+
48+
errors: list[str] = Field(default_factory=list)
49+
"""Errors encountered during replay."""
50+
51+
52+
class ReplayEngine:
53+
"""Executes a ``ReplayCase`` against a pair of services."""
54+
55+
def __init__(
56+
self,
57+
session_service: SessionServiceABC,
58+
memory_service: Optional[MemoryServiceABC] = None,
59+
):
60+
self._session_service = session_service
61+
self._memory_service = memory_service
62+
63+
async def run_case(self, case: ReplayCase) -> BackendResult:
64+
"""Execute all operations in *case* and return the raw result."""
65+
result = BackendResult()
66+
67+
setup = case.session_setup
68+
app_name = setup.get("app_name", "replay_test")
69+
user_id = setup.get("user_id", "test_user")
70+
session_id = setup.get("session_id", case.case_id)
71+
initial_state = setup.get("state", {}) or {}
72+
73+
session = await self._session_service.create_session(
74+
app_name=app_name,
75+
user_id=user_id,
76+
state=initial_state.copy(),
77+
session_id=session_id,
78+
)
79+
80+
last_event: Optional[Event] = None
81+
82+
for op in case.operations:
83+
try:
84+
await self._execute_op(op, session, result, last_event)
85+
if op.op == "append_event" and last_event is None:
86+
pass
87+
except Exception as exc:
88+
result.errors.append(f"{op.op}: {exc}")
89+
90+
if op.op == "append_event":
91+
last_event = self._build_event(op)
92+
elif op.op == "read_back":
93+
refreshed = await self._session_service.get_session(
94+
app_name=app_name,
95+
user_id=user_id,
96+
session_id=session_id,
97+
)
98+
if refreshed is not None:
99+
session = refreshed
100+
101+
events = getattr(session, "events", [])
102+
result.events = [e.model_dump(mode="json") for e in events]
103+
result.state = dict(session.state) if hasattr(session, "state") else {}
104+
105+
result.summaries = self._collect_summaries(session)
106+
result.memory_entries = self._collect_memory_entries(session)
107+
108+
return result
109+
110+
async def _execute_op(
111+
self,
112+
op: ReplayOp,
113+
session: Session,
114+
result: BackendResult,
115+
last_event: Optional[Event],
116+
) -> None:
117+
"""Dispatch a single replay operation."""
118+
op_type = op.op
119+
120+
if op_type == "append_event":
121+
event = self._build_event(op)
122+
await self._session_service.append_event(session, event)
123+
return
124+
125+
if op_type == "update_state":
126+
event = self._build_event(op)
127+
await self._session_service.append_event(session, event)
128+
return
129+
130+
if op_type == "inject_summary":
131+
self._inject_summary(session, op)
132+
return
133+
134+
if op_type == "store_memory":
135+
if self._memory_service and self._memory_service.enabled:
136+
await self._memory_service.store_session(session)
137+
return
138+
139+
if op_type == "search_memory":
140+
if self._memory_service and self._memory_service.enabled:
141+
query = op.query or ""
142+
response = await self._memory_service.search_memory(
143+
key=session.id,
144+
query=query,
145+
limit=10,
146+
)
147+
entries = response.memories if response else []
148+
result.memory_entries = [e.model_dump(mode="json") for e in entries]
149+
return
150+
151+
if op_type == "duplicate_append":
152+
if last_event is not None:
153+
dup = self._copy_event(last_event)
154+
try:
155+
await self._session_service.append_event(session, dup)
156+
except Exception:
157+
pass
158+
return
159+
160+
if op_type == "delete_session":
161+
app = getattr(session, "app_name", "replay_test")
162+
uid = getattr(session, "user_id", "test_user")
163+
await self._session_service.delete_session(app_name=app, user_id=uid, session_id=session.id)
164+
return
165+
166+
if op_type == "read_back":
167+
return
168+
169+
# ── event construction ─────────────────────────────────────────────
170+
171+
@staticmethod
172+
def _build_event(op: ReplayOp) -> Event:
173+
"""Build an ``Event`` from a replay operation dict."""
174+
parts: list[Part] = []
175+
176+
if op.text:
177+
parts.append(Part.from_text(text=op.text))
178+
179+
if op.function_call:
180+
fc = FunctionCall(
181+
id="call-replay",
182+
name=op.function_call.get("name", ""),
183+
args=op.function_call.get("args", {}),
184+
)
185+
parts.append(Part(function_call=fc))
186+
187+
if op.function_response:
188+
fr = FunctionResponse(
189+
id="resp-replay",
190+
name=op.function_response.get("name", ""),
191+
response=op.function_response.get("response", {}),
192+
)
193+
parts.append(Part(function_response=fr))
194+
195+
content = Content(parts=parts, role="user") if parts else Content()
196+
197+
actions = EventActions()
198+
if op.state_delta:
199+
actions.state_delta = op.state_delta
200+
201+
return Event(
202+
invocation_id="replay-inv",
203+
author=op.author or "user",
204+
content=content,
205+
actions=actions,
206+
partial=op.partial,
207+
)
208+
209+
@staticmethod
210+
def _copy_event(event: Event) -> Event:
211+
"""Create a structural copy of *event* with a fresh ID."""
212+
data = event.model_dump()
213+
data.pop("id", None)
214+
return Event(**data)
215+
216+
# ── summary injection ──────────────────────────────────────────────
217+
218+
def _inject_summary(self, session: Session, op: ReplayOp) -> None:
219+
"""Bypass the LLM and inject a ``SessionSummary`` into the cache."""
220+
manager: Optional[SummarizerSessionManager] = getattr(
221+
self._session_service, "_summarizer_manager", None)
222+
if manager is None:
223+
return
224+
225+
cache = getattr(manager, "_summarizer_cache", None)
226+
if cache is None:
227+
return
228+
229+
import time
230+
231+
summary = SessionSummary(
232+
session_id=op.session_id or session.id,
233+
summary_text=op.summary_text,
234+
original_event_count=op.original_event_count,
235+
compressed_event_count=op.compressed_event_count,
236+
summary_timestamp=time.time(),
237+
)
238+
239+
app_name = session.app_name
240+
user_id = session.user_id
241+
cache.setdefault(app_name, {})
242+
cache[app_name].setdefault(user_id, {})
243+
cache[app_name][user_id][session.id] = summary
244+
245+
summary_event = Event(
246+
invocation_id="summary",
247+
author="system",
248+
content=Content(
249+
parts=[Part.from_text(text=f"Previous conversation summary: {op.summary_text}")],
250+
role="user",
251+
),
252+
timestamp=time.time(),
253+
)
254+
summary_event.set_summary_event(True)
255+
session.events.insert(0, summary_event)
256+
257+
# ── result collection ──────────────────────────────────────────────
258+
259+
def _collect_summaries(self, session: Session) -> list[dict]:
260+
manager: Optional[SummarizerSessionManager] = getattr(
261+
self._session_service, "_summarizer_manager", None)
262+
if manager is None:
263+
return []
264+
cache = getattr(manager, "_summarizer_cache", None)
265+
if cache is None:
266+
return []
267+
entries = cache.get(session.app_name, {}).get(session.user_id, {})
268+
return [s.model_dump(mode="json") for s in entries.values()]
269+
270+
def _collect_memory_entries(self, session: Session) -> list[dict]:
271+
return [] # collected inline during search_memory op

tests/sessions/replay_harness/_report.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class CaseResult(BaseModel):
5454
diffs: list[DiffEntry] = Field(default_factory=list)
5555
"""All diffs found for this case."""
5656

57+
is_anomaly_case: bool = False
58+
"""True for injected-anomaly cases whose diffs are expected."""
59+
5760

5861
class ReportSummary(BaseModel):
5962
"""Aggregate statistics for the report."""
@@ -88,8 +91,8 @@ class DiffReport(BaseModel):
8891

8992

9093
def _compute_false_positive_rate(results: list[CaseResult]) -> float:
91-
"""Compute false-positive rate for passing cases."""
92-
passing = [r for r in results if r.status == "pass"]
94+
"""Compute false-positive rate for non-anomaly passing cases."""
95+
passing = [r for r in results if r.status == "pass" and not r.is_anomaly_case]
9396
if not passing:
9497
return 0.0
9598
false_positive_count = sum(1 for r in passing if r.diffs)

0 commit comments

Comments
 (0)