Skip to content

Commit f644afa

Browse files
committed
tests: add replay harness normalizer and unit tests
Implement normalization utilities that strip auto-generated and backend-dependent fields (timestamps, UUIDs, invocation IDs) from events, state, summaries, and memory entries before comparison. 24 unit tests cover: event field stripping, timestamp-to-index replacement, function_call/response extraction, state deep-sort determinism, summary timestamp removal, memory entry normalization, and the full normalize_backend_result orchestrator. Updates #89 RELEASE NOTES: NONE
1 parent 0cd9d28 commit f644afa

2 files changed

Lines changed: 478 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
"""Normalization utilities for replay consistency testing.
9+
10+
Strips auto-generated and backend-dependent fields so that results from
11+
different backends can be compared meaningfully.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
from typing import Any
18+
19+
from pydantic import BaseModel
20+
from pydantic import Field
21+
22+
23+
class NormalizedResult(BaseModel):
24+
"""Normalized view of a backend result suitable for comparison."""
25+
26+
events: list[dict] = Field(default_factory=list)
27+
"""Normalized event dicts with auto-generated fields stripped."""
28+
29+
state: dict = Field(default_factory=dict)
30+
"""Deep-sorted state dictionary."""
31+
32+
summaries: list[dict] = Field(default_factory=list)
33+
"""Normalized summary dicts with timestamps stripped."""
34+
35+
memory_entries: list[dict] = Field(default_factory=list)
36+
"""Normalized memory entry dicts with timestamps stripped."""
37+
38+
errors: list[str] = Field(default_factory=list)
39+
"""Errors encountered during replay (kept as-is)."""
40+
41+
42+
def _sort_dict_deep(obj: Any) -> Any:
43+
"""Recursively sort all dict keys for deterministic serialization."""
44+
if isinstance(obj, dict):
45+
return {k: _sort_dict_deep(v) for k, v in sorted(obj.items())}
46+
if isinstance(obj, list):
47+
return [_sort_dict_deep(v) for v in obj]
48+
return obj
49+
50+
51+
def normalize_events(events: list[dict]) -> list[dict]:
52+
"""Normalize a list of event dicts.
53+
54+
Strips auto-generated / backend-dependent fields (``id``, ``timestamp``,
55+
``invocation_id``, etc.) and replaces ``timestamp`` with a sequential
56+
index. Extracts text, function calls, function responses, and state
57+
deltas into a canonical form.
58+
"""
59+
normalized: list[dict] = []
60+
for idx, event in enumerate(events):
61+
norm: dict[str, Any] = {}
62+
63+
norm["index"] = idx
64+
norm["author"] = event.get("author", "")
65+
66+
content = event.get("content") or {}
67+
parts = content.get("parts") if isinstance(content, dict) else []
68+
69+
texts = []
70+
func_calls = []
71+
func_responses = []
72+
73+
if isinstance(parts, list):
74+
for part in parts:
75+
if not isinstance(part, dict):
76+
continue
77+
if part.get("text"):
78+
texts.append(part["text"])
79+
if part.get("function_call"):
80+
fc = part["function_call"]
81+
func_calls.append({
82+
"name": fc.get("name", ""),
83+
"args": fc.get("args", {}),
84+
})
85+
if part.get("function_response"):
86+
fr = part["function_response"]
87+
func_responses.append({
88+
"name": fr.get("name", ""),
89+
"response": fr.get("response", {}),
90+
})
91+
92+
norm["text"] = "".join(texts)
93+
norm["function_calls"] = func_calls
94+
norm["function_responses"] = func_responses
95+
96+
actions = event.get("actions") or {}
97+
if isinstance(actions, dict):
98+
norm["state_delta"] = actions.get("state_delta") or {}
99+
else:
100+
norm["state_delta"] = {}
101+
102+
norm["partial"] = event.get("partial", False)
103+
norm["visible"] = event.get("visible", True)
104+
norm["error_code"] = event.get("error_code")
105+
norm["error_message"] = event.get("error_message")
106+
107+
normalized.append(norm)
108+
109+
return normalized
110+
111+
112+
def normalize_state(state: dict) -> dict:
113+
"""Normalize a state dictionary by deep-sorting keys.
114+
115+
Returns a deterministic representation suitable for deep comparison.
116+
"""
117+
sorted_state = _sort_dict_deep(state)
118+
return json.loads(json.dumps(sorted_state, sort_keys=True))
119+
120+
121+
def normalize_summaries(summaries: list[dict]) -> list[dict]:
122+
"""Normalize summary dicts.
123+
124+
Keeps content and structural metadata; strips ``summary_timestamp``
125+
and any backend-specific metadata.
126+
"""
127+
normalized: list[dict] = []
128+
for summary in summaries:
129+
if not isinstance(summary, dict):
130+
continue
131+
norm: dict[str, Any] = {
132+
"session_id": summary.get("session_id", ""),
133+
"summary_text": summary.get("summary_text", ""),
134+
"original_event_count": summary.get("original_event_count", 0),
135+
"compressed_event_count": summary.get("compressed_event_count", 0),
136+
}
137+
normalized.append(norm)
138+
return normalized
139+
140+
141+
def normalize_memory_entries(entries: list[dict]) -> list[dict]:
142+
"""Normalize memory entry dicts.
143+
144+
Keeps content text and author; strips timestamp.
145+
"""
146+
normalized: list[dict] = []
147+
for entry in entries:
148+
if not isinstance(entry, dict):
149+
continue
150+
content = entry.get("content") or {}
151+
text = ""
152+
if isinstance(content, dict):
153+
parts = content.get("parts") or []
154+
if isinstance(parts, list):
155+
text = "".join(
156+
p.get("text", "") for p in parts if isinstance(p, dict))
157+
norm: dict[str, Any] = {
158+
"content_text": text,
159+
"author": entry.get("author", ""),
160+
}
161+
normalized.append(norm)
162+
return normalized
163+
164+
165+
def normalize_backend_result(
166+
events: list[dict],
167+
state: dict,
168+
summaries: list[dict],
169+
memory_entries: list[dict],
170+
errors: list[str],
171+
) -> NormalizedResult:
172+
"""Normalize a complete backend result for comparison."""
173+
return NormalizedResult(
174+
events=normalize_events(events),
175+
state=normalize_state(state),
176+
summaries=normalize_summaries(summaries),
177+
memory_entries=normalize_memory_entries(memory_entries),
178+
errors=errors,
179+
)

0 commit comments

Comments
 (0)