Skip to content

feat: Session/Memory 多后端回放一致性测试框架 (issue #89)#178

Open
wzxsdf wants to merge 7 commits into
trpc-group:mainfrom
wzxsdf:feat/session-memory-replay-consistency-89
Open

feat: Session/Memory 多后端回放一致性测试框架 (issue #89)#178
wzxsdf wants to merge 7 commits into
trpc-group:mainfrom
wzxsdf:feat/session-memory-replay-consistency-89

Conversation

@wzxsdf

@wzxsdf wzxsdf commented Jul 13, 2026

Copy link
Copy Markdown

概述

实现一个可复用的回放一致性(replay consistency)框架:用同一组标准化 Agent 轨迹驱动 InMemory / SQLite / Redis 三个后端,经规范化比较事件、state、memory 与 Session Summary,自动生成可定位到 session_id / event_index / summary_id / field_path 的差异报告。它既是测试工具,也是后端实现质量的基准。

Closes #89

主要改动

replay harness(tests/sessions/replay/)

  • harness.py:ReplayCase/ReplayOp/ReplayBackend/ReplaySnapshot 数据模型 + replay_case() 驱动 + _DeterministicSummarizer(覆写 _compress_session_to_summary,跑 SDK 真实压缩流程、不调 LLM)
  • normalizer.py:占位符归一化(保留字段存在性)、剥离 temp: state、memory 确定性排序
  • comparator.py:单一递归 visit(),DiffEntry 内联定位字段
  • allowed_diff.py:JSONPath 精确匹配(token 化,避开 fnmatch 字符集陷阱)+ 强制 reason + 覆盖率治理(条数 ≤ 8、占比 ≤ 10%)
  • summary_checks.py:loss / overwrite / affiliation 三类专项 + 分词 Jaccard 语义相似度
  • injectors.py:快照层注入 + 端到端后端注入
  • report.py / backends.py:schema_version=3 报告 / 三后端实例化 + env 门控

replay case(replay_cases/cases.jsonl):10 条,覆盖 issue 全部 8 类场景(单轮/多轮/工具调用/state 覆写/memory 读写/summary 生成与更新/summary 截断/异常恢复)

报告产物:session_memory_summary_diff_report.json(schema_version=3)
设计文档:docs/superpowers/specs/2026-07-13-session-memory-replay-consistency-design.md

后端与运行模式

模式 后端 触发
轻量(默认) InMemory vs SQLite(:memory:) 默认,≤ 30s
SQL 集成 + 自定义 SQL TRPC_REPLAY_SQL_URL
Redis 集成 + Redis TRPC_REPLAY_REDIS_URL,未设置则 pytest.skip

三后端显式传同一 SessionServiceConfig(store_historical_events=True) 消除默认值差异;memory 三后端 enabled=True;不要求本地安装真实 Redis/MySQL。

归一化与比较策略

  • 归一化:timestamp / id / invocation_id 用占位符替换(保留字段存在性,优于 pop 删除);剥离 temp: state;memory entry timestamp 归一化 + 排序;long_running_tool_ids 的 None / 空集合统一。
  • 严格比较:事件顺序、state 值、function_call 参数、memory 内容、summary 的 session 归属 / version / 覆盖关系。
  • allowed_diff:JSONPath 精确匹配 + 强制 reason + 治理上限,绝不无脑忽略。
  • summary 三分:文本走分词 Jaccard 语义比较(纯标准库,无 embedding 依赖),元数据严格相等。

检出验证(两层)

  • 快照层:deepcopy 改字段,验证比较器 + summary_checks 检出率(8 类不一致,覆盖 event/state/memory/summary)。
  • 端到端后端注入:跑完 case 后直接 UPDATE events SET author=... / 改 Redis key,重新 get_session 读出,验证 harness 对真实后端数据漂移的感知能力。

验收映射

# 验收点 落点
1 InMemory + 持久化对比 默认 InMemory vs SQLite
2 10 case 100% 检出注入 test_each_case_detects_injection + 8 类 kind + 端到端 SQL/Redis
3 误报率 ≤ 5% 正常 8 case FPR = 0
4 summary 三类 100% 检出 loss / overwrite / affiliation 各注入断言
5 报告定位字段 DiffEntry 含 session_id/event_index/summary_id/field_path/双后端值
6 轻量 ≤ 30s + env 开/跳 实测 ~7s;Redis env 门控

本地验证

结果
通过 30 passed
跳过 1(Redis,需 TRPC_REPLAY_REDIS_URL)
耗时 ~7s
lint yapf + flake8 通过

- replay harness: 同一组轨迹驱动 InMemory/SQLite/Redis,比较事件/state/memory/summary
- 归一化(占位符,保留字段存在性)+ JSONPath 精确 allowed_diff + 覆盖率治理
- summary: SDK 确定性模型 + 三分比较 + loss/overwrite/affiliation 检测
- 检出验证: 快照层注入 + 端到端后端注入(改 SQL 行 / Redis key 重读)
- 实测发现 SQLite summary 持久化 drift,标 KNOWN_DRIFT 只报告不改
- 29 tests pass, 1 skipped (Redis 需 TRPC_REPLAY_REDIS_URL)
@wzxsdf

wzxsdf commented Jul 13, 2026

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@e113610). Learn more about missing BASE report.

Additional details and impacted files
@@            Coverage Diff             @@
##             main        #178   +/-   ##
==========================================
  Coverage        ?   87.86456%           
==========================================
  Files           ?         482           
  Lines           ?       45157           
  Branches        ?           0           
==========================================
  Hits            ?       39677           
  Misses          ?        5480           
  Partials        ?           0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread tests/sessions/replay/IMPLEMENTATION_PLAN.md
Comment thread tests/sessions/session_memory_summary_diff_report.json
@raychen911

raychen911 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

在replay目录下增加一下REDAME.md文档,说明一下测试需要注意的地方,

回应维护者 raychen911 的 review(CHANGES_REQUESTED):

1. design.md 新增 §6.1「正/负向双向验证」:集中说明同一组 10 case
   既测正向(不注入→100% match, FPR=0)又测负向(注入→100% 检出),
   指向 test_replay_injections.py(快照层 8 种 kind + 端到端 SQL/Redis)。
2. plan + design 从 docs/superpowers/ 移入 tests/sessions/replay/,
   修正内部 SDK 源码相对链接(../../ → ../../../)。
3. session_memory_summary_diff_report.json 从仓库根移至 tests/sessions/,
   同步改 REPORT_PATH 与 report.py 注释,避免断链。
4. 新增 tests/sessions/replay/README.md(测试注意事项 7 条)。

验证: replay 测试 30 passed/1 skipped、yapf/flake8 全绿、全局引用无断链。
@wzxsdf

wzxsdf commented Jul 23, 2026

Copy link
Copy Markdown
Author

回应维护者 raychen911 的 review(CHANGES_REQUESTED):

  1. design.md 新增 §6.1「正/负向双向验证」:集中说明同一组 10 case
    既测正向(不注入→100% match, FPR=0)又测负向(注入→100% 检出),
    指向 test_replay_injections.py(快照层 8 种 kind + 端到端 SQL/Redis)。
  2. plan + design 从 docs/superpowers/ 移入 tests/sessions/replay/,
    修正内部 SDK 源码相对链接(。
  3. session_memory_summary_diff_report.json 从仓库根移至 tests/sessions/,
    同步改 REPORT_PATH 与 report.py 注释,避免断链。
  4. 新增 tests/sessions/replay/README.md(测试注意事项 7 条)。

验证: replay 测试 30 passed/1 skipped、yapf/flake8 全绿、全局引用无断链。

Comment thread tests/sessions/replay/IMPLEMENTATION_PLAN.md
- tests/sessions/replay/2026-07-13-session-memory-replay-consistency.md → IMPLEMENTATION_PLAN.md
- 同步更新 design.md 目录树与 README.md 引用
@wzxsdf
wzxsdf requested a review from raychen911 July 24, 2026 12:27
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

基于我对 diff 和 SDK 上下文的深入分析,以下是我的审查结果。

发现的问题

🚨 Critical

  • tests/sessions/replay/injectors.py:153-153inject_sql_diffstate_value 分支对 SQLite 执行 json_set,但 app_states.state 列在 SQLite 方言下是 TextDynamicJSON 默认 Text,见 trpc_agent_sdk/storage/_sql_common.py:214),json_set 是 SQLite 的 JSON1 函数、作用于 TEXT 时需配合 json/CAST,直接对一个被 DynamicJSON 序列化过的字符串调用 json_set 会产生双重序列化或函数错误,注入后读回的 state 与 event_author 路径不可比。建议改为 Pydantic/Python 层 json.loads→改值→json.dumps 后整体 UPDATE,与 SDK 写入语义一致。
    # 当前:对 TEXT 列直接 json_set,格式与 SDK 写入不匹配
    text("UPDATE OR REPLACE app_states "
         "SET state = json_set(state, '$.injected', :v) WHERE app_name = :a"),

⚠️ Warning

  • tests/sessions/replay/injectors.py:154-154inject_redis_diffredis.from_url(redis_url) 构造客户端但未设 decode_responses=True,而 SDK 的 RedisSessionService(非 Cluster)默认也不设(对比 _redis_cluster.py:65setdefault("decode_responses", True))。若注入测试在开启了 decode_responses 的 Redis 上跑,client.get(key) 返回 str,json.loads(raw) 正常;若返回 bytesjson.loads(bytes) 虽可工作,但 client.set(key, json.dumps(data)) 写回的是 str,与 SDK 写入格式需严格一致才能被 Session.model_validate_json 还原。该端到端路径仅在 TRPC_REPLAY_REDIS_URL 启用时跑,建议显式 decode_responses=True 并用与 SDK 相同的 model_dump_json 写回,避免注入本身引入格式差异导致检出假阳/假阴。

  • tests/sessions/replay/injectors.py:75-75create_engine(db_url) 创建的 engine 在函数返回后未 dispose()。该函数被 test_event_author_drift_detected 多次调用(注入前后各一次,且每个用例独立),会累积 SQLite 文件 DB 的连接池/文件句柄,在 Windows 或并发跑时可能触发 ResourceWarning 或文件锁。建议用 with engine.begin()engine.dispose(),或 try/finally

  • tests/sessions/replay/backends.py:1017-1018redis_backendRedisSessionService(is_async=False) 默认同步,而 RedisMemoryService 同样同步;但 harness 在 async def replay_case 里直接 await mem.store_session(...),依赖 SDK 内部 execute_command 对同步 SyncRedisasyncio.iscoroutine 兜底。该路径仅在 Redis 启用时触发,风险可控,但与 SDK 推荐的 is_async=True 不一致,在真实 Redis 下若 redis-py 同步调用阻塞事件循环可能拖慢轻量模式超时断言。建议 Redis 后端统一 is_async=True

  • tests/sessions/replay/harness.py:201-201memory_searchmem is None 时静默 continue(无 memory_results 写入),但 memory_preference/memory_fact_update 的注入计划(_CASE_INJECTION)依赖 snap.memory 非空才能触发 memory_content 检出。若后端 enabled 未生效或 store_session 未实际落库,snap.memory 为空会让注入分支 if snap.memory: 跳过,test_each_case_detects_injection 会漏检却不报错(因为 _detected 返回 False 时只是加入 not_detected)。建议在 replay_case 末尾或注入前断言 memory 非空,避免「case 结构支持」假设落空。

  • tests/sessions/test_replay_consistency.py:133-133report["known_drift_cases"] 在 E2E 里手动塞入,但 build_diff_report 的 schema(report.py)并未声明该字段,而同 PR 纳入版本管理的基线 session_memory_summary_diff_report.json 也含该字段。这造成「报告 schema」与「实际产物」不一致:任何仅依据 report.py schema 消费报告的工具会丢失 known_drift_cases。建议把 known_drift_cases 纳入 build_diff_report 的返回结构(或在文档明确这是 E2E 扩展字段)。

💡 Suggestion

  • tests/sessions/replay/summary_checks.py:25-25SUMMARY_SIM_THRESHOLD = 0.8summary_text_similarity 定义后,全仓库无任何调用方使用该阈值(grep 仅命中定义与单测),设计文档宣称的「文本语义比较」实际未接入 check_summary_issues。该 dead code 会让后续维护者误以为语义比较已生效。建议要么在 check_summary_issues 中接入(对 summary.current.text 做相似度判定并产出 issue),要么删除未用的常量与函数以避免误导。

总结

整体是一个结构清晰、正向/负向双向验证的设计,主要风险集中在端到端注入工具与 SDK 实际存储格式的不匹配(SQL json_set、Redis decode_responses/写回格式),其中 SQL state_value 注入的格式错配是必须修复的 Critical 问题;其余为 Redis 启用路径与资源/字段一致性的改进项。

测试建议

  • 补充 inject_sql_diff(state_value) 的端到端测试:注入后用全新 SqlSessionService 读回,断言读回的 state 含注入键且与 SDK 正常写入格式可被 compare_snapshots 正确比较(当前 test_event_author_drift_detected 只覆盖 event_author)。
  • 补充 Redis 端到端 state_value 注入路径(当前 inject_redis_diffstate_value 分支无任何测试覆盖)。

wzxsdf added 3 commits July 24, 2026 20:53
- Critical: inject_sql_diff(state_value) 改为 Python 层处理避免双重序列化(helloopenworld)
- inject_redis_diff: 设置 decode_responses=True + 资源清理(client.close())
- redis_backend: 统一 is_async=True 与 SDK 推荐一致
- harness: 添加 case 涉 memory 操作时 memory 非空断言(避免注入漏检)
- report: known_drift_cases 纳入 DiffReport schema(schema 一致性)
- summary_checks: 删除 SUMMARY_SIM_THRESHOLD 死代码(避免误导)
- 测试补充: 新增 SQL/Redis state_value 端到端测试

验证: replay 测试 4 passed/2 skipped(Redis opt-in),无回归。
helloopenworld review 建议删除未用常量/函数,但 summary_text_similarity 实际被
test_replay_unit.py 单测覆盖(line 218-220),误删导致 CI test step4 ImportError。
现恢复 summary_text_similarity + _tokenize(保留单测),仅删除真正无调用方的
SUMMARY_SIM_THRESHOLD 常量。本地 replay 测试 31 passed/2 skipped。
7c2ab46 响应 helloopenworld schema 建议时误加了 DiffReport class,但 build_diff_report
仍返回 dict,DiffReport 全仓库无引用(dead code)。tRPC-Python-Agent-review bot 静态分析
在 264061c(无该 class)=success、7c2ab46(加入)=failure,高度吻合。
现删除该未使用 class,known_drift_cases 字段保留在 build_diff_report 返回 dict 中。
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

已确认行号。正在撰写审查报告。

发现的问题

🚨 Critical

无。

⚠️ Warning

  • tests/sessions/replay/report.py:1793build_diff_reportfalse_positive_rate 计算把 known_drift 案例也计入分母,与设计 §6「FPR 仅按正常 case 计算」相矛盾。
    • normal_mismatch 统计的是所有 mismatch 状态 case(含 summary_update/summary_truncation 这两个已知 drift),len(case_results) 又是全部 case 数,因此 drift 案例既进了分子又进了分母。在当前提交的 10 case 场景下该函数会算出 0.2(2/10)而非正确的 0.0
    • 主 E2E (test_replay_consistency.py:2599) 靠 report["false_positive_rate"] = fpr 手动覆盖才修正,但 report.py 自身契约是错的:单测 test_report_schema_and_totals 只构造无 drift 的 2 case,恰好掩盖了该错误,任何复用 build_diff_report 的调用方都会拿到错误的误报率。建议让 build_diff_report 接收 known_drift_cases(与 known_drift_cases 字段同源)并在内部排除,或显式以正常 case 集合计算 FPR,去掉 E2E 的覆盖式修补。

💡 Suggestion

  • tests/sessions/test_replay_injections.py:2661CASES_DIR = "tests/sessions/replay/replay_cases" 是相对当前工作目录的硬编码路径,而同批的 test_replay_consistency.py:2497 用的是 Path(__file__).parent / ...。两者不一致,当从非仓库根目录运行 pytest 时注入侧用例会找不到;统一改用基于 __file__ 的路径即可消除脆弱性。

  • tests/sessions/replay/injectors.py:1557UPDATE OR REPLACE app_statesapp_name 是主键会先删后插,非主键列(如 update_time)被重置为默认值;虽然 TTL 已 clean_ttl_config 关闭、当前不会过期,但普通 UPDATE 语义更清晰且不丢列,建议改为 UPDATE app_states SET state = :s WHERE app_name = :a

总结

整体风险较低:核心 SDK 覆写点(_compress_session_to_summary)、Redis/SQL key 与表结构、序列化格式均已对照源码核实,注入与检出逻辑可正常工作,无阻塞级 Critical 问题。主要问题是 build_diff_report 的误报率计算口径与设计不符(被 E2E 静默覆盖,单测未覆盖到),建议修复以避免报告函数被复用时输出错误指标。

测试建议

  • build_diff_report 补一个含 known_drift/mixture 的单测:构造 1 个正常 mismatch + 1 个 drift mismatch,断言 FPR 只按正常 case 计算(drift 不进分母),以锁定该函数契约。

Warning: build_diff_report 的 FPR 此前把 known_drift 计入分子分母,与设计 §6
「仅按正常 case 计算」矛盾(10 case 会算出 0.2 而非 0.0,E2E 靠手动覆盖修正,
单测因只构造无 drift case 而掩盖)。现加 known_drift_cases 参数(向后兼容),
内部排除 drift 计算 FPR;E2E 改为传参并去掉覆盖式修补。

Suggestion:
- injectors.py: UPDATE OR REPLACE → UPDATE(避免主键先删后插重置非主键列)
- test_replay_injections.py: CASES_DIR 改基于 __file__(与 consistency 一致,消除硬编码)

测试: 补 test_report_fpr_excludes_known_drift mixture 单测(1 drift + 1 正常 mismatch
+ 1 正常 match),锁定 FPR=0.5 排除 drift 契约。

验证: replay 测试 32 passed/2 skipped、yapf/flake8 全绿。
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

好的,两个内存后端匹配方式相同。报告确认 memory 用例匹配。很好。

我现在已经深入了解了。让我识别一下实际值得报告的问题。让我重新考虑一下 summary_text_similarity 函数 —— 它虽然导出了但没有被使用(文档字符串也承认了这一点)。它是死代码,但有单元测试覆盖。这是一个 Suggestion 级别的问题。

让我检查一下 extra_event 注入检测snap.events.append({"author":"INJECTED","content":{"parts":[{"text":"x"}]}})。比较器:列表长度不匹配 → 参考端没有额外事件 → <missing> 差异。已检测到。但是 inject_snapshot_diff 执行了 snap.events.append(...) —— 这是修改 model_copy(deep=True) 后的列表。Pydantic v2 模型默认允许列表修改。没问题。

现在让我更仔细地重新考虑一下 event_text 注入content = snap.events[0].get("content")content["parts"][0]["text"]="INJECTED"。这会修改深拷贝中的字典(因为是深拷贝,字典是独立的)。比较器检测到 events[0].content.parts[0].text 不同。很好。

现在,summary_overwrite 快照注入中的一个真正问题:它设置了 cur["version"]=0。但 check_summary_issues 仅在 cand_ver < ref_ver 时检测到覆盖。如果 ref_ver=1cand_ver=0 → 0<1 → 检测到。但 _detected 也会通过 compare_snapshots 进行检查,它会比较 summary.current.version 1 和 0 → 差异。所以即使没有摘要检查也能检测到。很好。

现在让我验证 summary_affiliation 注入:设置 cur["session_id"]="wrong-session"compare_snapshots 比较 summary.current.session_id → 差异。另外 check_summary_issues 归属错误。已检测到。很好。

现在让我重新考虑我标记的那个潜在 真正的 Bug:在 compare_snapshots 中,summary_id 仅在 ref_current 为真时被设置为 f"{reference.session_id}:summary"。对于 summary_loss 注入,ref_current 为真(基准有摘要),候选端为 None。summary_id 设置正确。差异路径为 summary.current。没问题。但如果参考端没有摘要(基准 summary_out={"current":None}),则 summary_id 保持为 None。问题不大。

现在让我考虑 summary_create测试有效性:它在 test_all_eight_kinds_detected 中被用于所有 3 种摘要注入类型。但 summary_create 是否确实生非 None 的摘要?3 个事件,确定性摘要器返回 "DETERMINISTIC SUMMARY: [user]... | [agent]..."。非 None。所以 current 已填充。注入操作正常。很好。

现在让我确定真正有影响的问题。让我重新检查 DeterministicSummarizerbase_ts = time.time() 在非确定性方面的隐患:它在单次 replay_case 调用内部用于 base_ts + event_seq。由于 timestamp 会被归一化,时间值多少并不重要。但在同一用例的两个后端中,base_ts 不同(两次独立的调用)。归一化处理消除了这一点。很好。

现在是一个真正值得关注的点:harness replay_case 读取 mgr.get_session_summary(got) —— 但 get_session_summary 是从内存中的 _summarizer_cache 读取,而不是从持久化存储中读取。对于 SQLite,在执行 create_session_summaryupdate_session 后,摘要被压缩到 session.events 中的摘要事件中,并且 SessionSummary 存在于管理器缓存中。但由于每个后端都有自己的管理器(_manager() 每次调用都会创建一个新的),缓存是独立的。在 replay_case 中,mgr 是当前后端的,所以 get_session_summary 有效。很好。

但这是一个真正的问题:harness 构建了 summary_out["current"]["version"] = summary_version,这是一个整数计数器。对于 summary_create,version=1。对于 summary_update,version=2(create_summary+update_summary)。等等 —— harness 在 create_summaryupdate_summary 中都会增加 summary_version。所以 summary_create → version=1,summary_update → version=2。报告显示 summary_update 不匹配。但在“正常”(非漂移)路径中,summary_create 在 InMemory 和 SQLite 之间匹配吗?两者都有 version=1,相同的文本。报告显示 summary_create 匹配。很好。

现在让我检查一下 update_summary 操作是否执行了有意义的更新。Harness:mgr.create_session_summary(target, force=True) 然后 svc.update_session(target)。两次都增加了版本。两次都重新压缩。这就是报告差异中出现的“双重压缩”问题(InMemory 的文本中有重复的 "[user] 改去大阪 | [user] 改去大阪")。这是一个已知漂移。好的。

现在我有信心了。让我把真正的发现写下来。让我重新考虑一下是否存在任何 Critical(关键)问题。

让我重新检查一件事 —— enabled_backends 中的 tmp_path 默认值 :memory: 是否会在 E2E 测试中导致问题:E2E 测试使用 enabled_backends() → SQLite 使用 :memory:。但 replay_case 为每个用例创建一个新的 SqlSessionService。对于 :memory:,每次实例化都会创建一个全新的独立数据库。在每个用例中,一个后端实例被复用用于该用例的所有操作,然后获取会话 —— 全部在同一个引擎上。所以数据是持久存在的。很好。没问题。

现在,一个真正的问题:test_all_cases_cross_backend 使用了一个 in_memory 引用后端,它在整个用例循环中被复用。InMemorySessionService 会累积所有用例的会话。由于每个用例使用不同的 app_name,所以不会冲突。但内存搜索使用 save_key=user_key(app,user) —— 每个用例都有不同的应用 → 不同的 save_key。所以不会交叉污染。很好。

让我验证 InMemory 后端 没有被复用服务中的 TTL 问题clean_ttl_config 禁用了 TTL,所以不会过期。很好。

现在让我考虑 summary_text_similarity死代码 问题 —— 这是一个已记录的预留函数。顶多算个 Suggestion(建议)。

让我检查最后一个潜在的实际 Bug:在 inject_redis_diff 中,对于 state_value,它执行了 client.hset(f"app_state:{app_name}", "injected", "INJECTED")。但是 SDK 的 _get_app_state 执行 hgetall 并返回一个字典。合并后的状态将包含 app:injected。但是 Redis 测试 test_state_value_drift_detected 构建 before/after 快照时仅从 got.events(没有状态!)来看 —— ReplaySnapshot(backend_name="redis", session_id="sess-state", events=[...])。状态默认为 {}。因此 before.state={}after.state={} —— 两者都为空!比较器在状态上没有发现差异。让我重读一下测试。

查看 test_state_value_drift_detected(Redis,约 2867-2902 行):before = 仅事件的快照(state={})。after = 仅事件的快照(state={})。然后 compare_snapshots(before, after, ...)。由于两个快照的 state 都是 {},所以不会出现状态差异。断言是 assert real and any("injected" in d.field_path for d in real)。但 real 将为空(没有状态差异,事件相同)→ 断言失败!

等等,这是 Redis 测试,默认会被跳过(没有 TRPC_REPLAY_REDIS_URL)。所以它在 CI 中不会运行。但它确实是一个损坏的测试。让我重读一下 SQL 版本,进行对比。

SQL 版本(test_state_value_drift_detected,约 2796 行):使用 _read_sql_snapshot,它构建包含 state=dict(got.state) 的快照。所以 SQL 版本包含了状态。对于 SQL 版本,before/after 包含了状态。所以 SQL 版本是有效的。

但是 Redis 版本只使用了 events=[...],没有状态。所以当启用 Redis 时,Redis 的 test_state_value_drift_detected 会失败。这是一个真正的 Bug,但仅影响启用 Redis 的 CI(可选)。由于指令说明测试代码要按实际执行风险进行评估,并且这会在 Redis CI 运行时失败,这是一个警告(Warning)。

让我确认一下 Redis 的 state 测试确实没有填充状态。

查看 2882-2884 行:before = normalize_snapshot(ReplaySnapshot(backend_name="redis", session_id="sess-state", events=[e.model_dump() for e in got.events]))。没有 state=。默认为 {}。2889-2891 行的 after 也是一样。所以 before.stateafter.state 都是 {}。在 app_state 中注入的 "injected" 永远不会被读取到快照中。比较器在状态上没有发现差异。断言失败。

这是一个真正的 Bug —— Redis 的 state_value 漂移测试从不读取状态,因此它无法检测到状态漂移。当启用 Redis CI 时,它会失败。值得作为一个警告(Warning)。

现在让我再重新考虑一下 SQL 的 test_state_value_drift_detected —— 它使用 _read_sql_snapshot,其中包含了 state=dict(got.state)。所以注入的 app:injected 确实出现在状态中。有效。很好。

让我再验证一下 Redis 的 test_event_author_drift_detected 是否有效:before/after 是仅包含事件的快照。注入修改了 events[0].author。比较器检测到事件差异。有效。很好。

所以只有 Redis 的状态测试坏了。让我再检查一下:Redis test_state_value_drift_detectedbefore 是从 got.events 构建的,after 也是从(重新获取的)got2.events 构建的 —— 等等,在 Redis 状态测试中,after 是从 after = await backend.session_service.get_session(...) 构建的,然后是 ReplaySnapshot(..., events=[e.model_dump() for e in after.events])。事件没有改变(注入是在 app_state 哈希中,而不是会话中)。所以事件相同。状态为空。没有差异。断言失败。已确认。

现在让我再重新考虑一下 SQL inject_sql_diffstate_value 是否读取了正确的列。它执行 SELECT state FROM app_states WHERE app_name = :aapp_states.state 列是 DynamicJSON(文本)。原始值是 JSON 字符串。row[0] 是字符串。json.loads 有效。很好。但是等等 —— fetchone() 返回行;row[0] 是状态值。但 SQLAlchemy 的原生文本查询返回原始列值。由于 DynamicJSON 是一个 TypeDecorator,原生文本查询会绕过 process_result_value 吗?是的 —— 原生文本返回原始存储值(JSON 字符串)。所以 json.loads(row[0]) 是正确的。很好。

现在让我确定发现的问题。让我检查一下 is_allowedbackend_pair 比较 是否有细微的 Bug。rule.backend_pairOptional[tuple[str,str]]。在 Pydantic 中,从 JSON 解析出的元组会变成列表还是元组?在 pydantic v2 中,tuple[str,str] 会强制转换为元组。tuple(rule.backend_pair)tuple(backend_pair) 进行比较。没问题。

让我检查一下比较器中 _format_path 的首段问题:对于 path=["events"](仅根节点),它会生成 "events"。对于 ["events", 0] → "events[0]"。对于 ["state", "app:injected"] → "state.app:injected"。很好。对于 ["summary", "current", "version"] → "summary.current.version"。与 is_allowed 标记匹配。很好。

现在,allowed_diff 中的 通配符匹配_tokenize_path("events[*].timestamp")[("key","events"),("idx","*"),("key","timestamp")]。字段路径 events[0].timestamp[("key","events"),("idx","0"),("key","timestamp")]_match_tokens:长度相同,类型相同,rval="*" → 继续。匹配。很好。并且 events[0].author 不会匹配 events[*].timestamp(最后一个标记不同)。很好。

现在让我检查一个潜在的 实际问题ReplayOpfunction_response 字段是 Optional[Any]。在 JSONL 中,function_response 是一个字典。Part.from_function_response(name=..., response=op.function_response or {})。没问题。

让我验证测试框架中的 function_callPart.from_function_call(name=op.function_name or "f", args=op.function_args or {})。没问题。

好的,我已经充分覆盖了。让我再快速验证一下 _diff 测试辅助函数和 test_report_schema FPR=0.5 是否一致:2 个用例(c1 匹配,c2 不匹配),没有 known_drift → FPR=1/2=0.5。断言为 0.5。很好。test_report_fpr_excludes_known_driftdrift_one(不匹配,在 known_drift 中),normal_mismatch(不匹配),normal_match(匹配)。FPR=1/2=0.5。断言为 0.5。很好。

现在让我再重新考虑一下 E2E 测试中 known_drift 用例的不匹配是否被正确归因。E2E 断言对于 KNOWN_DRIFT 用例,sqlite_comp[0].status == "mismatch"。报告显示 summary_updatesummary_truncation 是不匹配的。很好。并且 false_positive_rate==0.0(8 个正常用例全部匹配)。报告显示 FPR 为 0.0。很好。

现在让我重新考虑一下已提交的报告工件是否是 测试期间重新生成 的。test_all_cases_cross_backend 调用 write_report(report, REPORT_PATH),这会覆盖已提交的 JSON。所以提交的工件是过时的快照,但测试会重新生成它。没问题 —— 但存在一个微妙的问题:测试断言 report["false_positive_rate"] == 0.0 等。如果环境不同(例如启用了 Redis),enabled_backends 会包含 Redis,并且 Redis 用例可能会增加漂移。但 Redis 的 pytest.skip 发生在……等等,不对 —— enabled_backends 本身并不会跳过;如果 URL 设置,它会尝试构建 Redis 后端,捕获异常 → 标记为跳过状态,但不会跳过测试。如果 Redis 可达,它会包含 Redis 比较。那么 summary_update/summary_truncation 可能会在 Redis 上漂移或匹配。然后 false_positive_rate 可能会改变(Redis 的不匹配会计入正常用例)。

实际上,KNOWN_DRIFT 用例是从 FPR 中排除的。对于 8 个正常用例,如果 Redis 产生了不匹配,FPR 将大于 0,并且测试断言 FPR==0 将失败。因此,如果存在真实的 Redis 且行为漂移,E2E 测试在启用 Redis 时可能会失败。但默认情况下(无 Redis),它通过。这是设计的风险(Redis 是可选的)。考虑到不确定性,对于默认路径,我会将其标注为低严重性的警告或忽略。

让我最终确定。我发现的真正问题是:

  1. Redis test_state_value_drift_detected 从未读取状态 → 无法检测到状态漂移,断言在启用 Redis 时会失败。警告。

让我再重新考虑一下 SQL 状态测试中是否有任何问题:before_read_sql_snapshot 通过新的服务读取。但 inject_sql_diff 创建了一个独立的引擎。在 :memory: 上……不对,SQL 测试使用 tmp_path 文件数据库。所以注入器的引擎和服务的引擎都指向同一个文件。很好。但有一个微妙的问题:_read_sql_snapshot 创建了一个新的 SqlSessionService(db_url)。每个都会创建表(create_all 是幂等的)。从文件读取。很好。

等等 —— 实际上在 SQL test_state_value_drift_detected 中有一个真正的问题:beforeafter 都通过 _read_sql_snapshot 读取,它会调用 get_session。但是 get_session 也会刷新 TTL 并提交。关键在于,注入发生在 beforeafter 之间。after 读取注入的状态。但是 get_session_get_app_state 返回 storage_app_state.state —— 注入器写入的 JSON。所以 after.state 包含 app:injectedbefore.state 不包含。检测到差异。很好。

现在让我再验证一下 _read_sql_snapshot 是否省略了内存和摘要 —— 它只构建事件/历史事件/状态。对于状态测试,这样就足够了。很好。

让我再重新考虑一下是否存在 资源泄漏inject_sql_diff 在 finally 中处理引擎。inject_redis_diff 关闭客户端。很好。

现在让我重新考虑一下 enabled_backendsbackend_statuses 的初始化状态in_memory 被设置为状态 "match",这很奇怪(它总是参考,而不是被比较)。但报告的 compared_backends 排除了跳过的 → [in_memory, sqlite]。报告显示 compared_backends=[in_memory, sqlite]。但 in_memory 是参考后端,而不是候选后端。_compared_backends 包含了所有非跳过状态的后端,包括 in_memory。这是报告中的一个小不一致(参考后端不应出现在“已比较”列表中),但不是功能性 Bug。提交的报告显示 compared_backends=[in_memory, sqlite]。设计文档说 compared_backends:["sqlite","redis"](不包括参考)。所以实现与设计不符。这是一个值得注意的 Suggestion/Warning —— compared_backends 错误地包含了参考后端。

让我验证一下:_compared_backends(statuses, ...)[b.name for b in statuses if b.status != "skipped"] = [in_memory, sqlite](因为两者都是 "match")。所以 compared_backends 包含了 in_memory(参考)。设计文档 §4.8 的示例显示 compared_backends: ["sqlite", "redis"](仅候选)。因此,实现错误地将参考后端包含在 compared_backends 中。E2E 测试断言 "sqlite" in report["compared_backends"](较弱,通过)。但该字段在语义上是错误的。这是一个 Suggestion/低严重性 Warning。

现在让我完成最终结论。主要问题:

  1. Redis state_value 测试损坏(警告)。
  2. compared_backends 包含参考后端(建议)。
  3. summary_text_similarity 死代码(建议 —— 最多 2 条)。

让我重新考虑 #1 的严重性 —— 它在跳过的测试中,因此不会阻塞 CI。但根据指令,跳过/可选的测试仍然要按实际执行风险进行评估;如果 Redis 可达,它就会运行并失败。这是一个警告(测试有效性)。我会将其标记为警告。

让我最后再重新考虑一下 harness 的 case_involves_memory 断言是否存在 误报 风险。对于 memory_preference/memory_fact_update,如果内存服务静默失败,它会引发 AssertionError 并显示一条消息。这可能会掩盖真实的用例失败。但它具有保护性(可以捕获静默的内存故障)。可以接受。

现在让我再次检查 normalize_eventlong_running_tool_ids 归一化if lr is None or (hasattr(lr,"__len__") and len(lr)==0): out["long_running_tool_ids"]=None。这会在事件字典级别将 set()None 都归一化为 None。注释说是 InMemory 与 SQL 的差异。合理。没问题。

让我再重新考虑一下 _visit 比较器处理 historical_events event_index 的可能性,它可能会产生误导性的定位,但不会导致不正确的通过/失败。影响小。跳过。

让我验证一下没有 安全性 问题:注入器使用参数化查询(没有 SQL 注入)。Redis URL 来自环境变量。没有凭据泄漏。报告包含会话内容(文本) —— 不是敏感信息。没问题。

现在让我重新考虑一下已提交的 session_memory_summary_diff_report.json 是否 以可能不稳定的全量事件字典值进行跟踪。报告说“不嵌全量 snapshot”,但单个差异 reference_value 包含完整的事件字典(200+ 行)。这是因为 <missing> 差异包含了整个事件。这是已提交的工件 —— 值得注意吗?它是生成的工件,测试会重新生成。不是 Bug。跳过。

让我最终完成。为了全面起见,让我再重新考虑一下 E2E 测试的 assert elapsed < LIGHTWEIGHT_TIMEOUT —— 如果 CI 很慢,可能会不稳定。30秒运行 10 个用例 × 2 个后端。每个用例涉及多个异步数据库操作。在慢速 CI 上有风险,但通常没问题。是关于不稳定性的低严重性说明。我会将其包含为次要警告吗?指令说要合并。我将其作为警告提及,因为它可能导致 CI 不稳定。

实际上让我重新考虑一下 —— E2E 创建了 2 个后端(in_memory,sqlite :memory:)并运行了 10 个用例。SQLite :memory: 很快。20秒应该足够了。在负载下 30秒可能会很紧张。但这是一个真正的超时风险。我会提到它。

让我写下审查意见。

实际上,让我重新考虑一下 Redis 状态测试是真的是坏的,还是我误读了。让我重读 2880-2902 行。

2880-2883 行:got = await backend.session_service.get_session(app_name="replay-state", user_id="u1", session_id="sess-state") 然后 before = normalize_snapshot(ReplaySnapshot(backend_name="redis", session_id="sess-state", events=[e.model_dump() for e in got.events]))。所以 before 只有事件,state={默认值}。

2884 行:assert inject_redis_diff(redis_url, "replay-state", "u1", "sess-state", "state_value") —— 注入到 app_state 哈希中。

2886-2891 行:after = await get_session(...) 然后 after = normalize_snapshot(ReplaySnapshot(..., events=[...]))。同样只有事件。

所以 before.state = {}after.state = {}。事件未改变。compare_snapshots → 无差异。real = []。断言 assert real and any(...)assert [] → 失败。已确认损坏。

这是一个真正的 Bug。警告。

让我再检查一下相同的 Redis 测试是否至少通过 got.state 读取了状态 —— 没有。它只读取了 got.events。SQL 版本通过 _read_sql_snapshot 包含了 state=dict(got.state)。所以 Redis 版本在移植时存在 bug。已确认。

现在让我写下结论。我也重新考虑一下 compared_backends 包含参考后端的问题 —— 这是一个真正的语义偏差,但影响较小。我将其作为一条建议。

让我最后确定 Suggestion 的数量限制(最多 2 条):

  1. compared_backends 包含参考后端(语义偏差)。
  2. summary_text_similarity 导出但未使用(死代码)。

两个都还可以。实际上 #2 在文档字符串中已有说明并预留;价值较低。我还是只保留 #1 作为建议吧,再加一条关于 E2E 超时不稳定性的内容。嗯。让我挑出 2 条最有价值的。

实际上,关于 E2E 30秒超时导致不稳定性的担忧是比 compared_backends 语义更有价值的警告。让我重新考虑一下分级:

  • 警告:Redis 状态测试损坏;E2E 硬编码 30秒超时存在不稳定性风险。
  • 建议:compared_backends 包含参考后端。

让我重新考虑一下 E2E 超时是否真的有风险。测试执行 10 个用例 × 2 个后端 = 20 次 replay_case 运行,每一次都执行 create_session + 几个 append_event + get_session。SQLite :memory: 操作为毫秒级。20 次运行 × ~每项几毫秒 = 远低于 5 秒。30 秒的时间很充裕。不稳定风险很低。我会降低其严重性或放弃它。我还是放弃它吧 —— 不太可能触发。

让我最终确定为:1 个警告(Redis 状态测试),1 条建议(compared_backends)。

让我按照要求的格式写出来。基于我对 pr.diff 及相关 SDK 上下文的审查,我的发现如下。

发现的问题

⚠️ Warning

  • tests/sessions/test_replay_injections.py:2867-2902:Redis 端到端 state_value 注入测试从不读取 state,无法检出 state 漂移
    • before/after 快照仅用 events=[...] 构造(ReplaySnapshot(..., events=[...])),未传 state=,故两者 state 均为默认 {};注入写的是 app_state:{app} hash(不在 session JSON 里),而读回时又只取 got.events,state 始终为空。对比 SQL 版用的是 _read_sql_snapshot(...)state=dict(got.state),能正确检出。
    • 该测试 pytest.skip 于无 TRPC_REPLAY_REDIS_URL 时,但一旦 Redis 可达(集成 CI)即 assert real and ... 失败。修复:像 SQL 版一样把 state=dict(got.state)(必要时还有 summary)填进 before/after 快照,使 app:injected 进入被比较的 state。
      before = normalize_snapshot(
          ReplaySnapshot(backend_name="redis", session_id="sess-state",
                         events=[e.model_dump() for e in got.events],
                         state=dict(got.state)))  # 当前缺失 state=,导致漂移不可见

💡 Suggestion

  • tests/sessions/replay/report.py:1750-1759compared_backends 把参考后端 in_memory 也列入,与设计文档语义不符
    • _compared_backends 取所有 status != "skipped" 的后端名,结果为 ["in_memory","sqlite"],而设计 §4.8 示例为 ["sqlite","redis"](仅候选)。参考后端不应出现在「已比较」列表中。E2E 仅断言 "sqlite" in ...(弱断言)故未暴露。建议排除 reference_backend,保持报告语义与设计一致。

总结

整体框架设计严谨、正向/负向双向验证思路扎实,默认轻量路径未发现阻塞问题。主要风险是 Redis state_value 端到端测试构造快照时漏读 state,导致该用例在 Redis 可达时会必然失败,建议修复后再启用 Redis 集成 CI。

测试建议

  • 补一条针对 Redis state_value 注入的修复验证:注入后断言 after.stateapp:injectedcompare_snapshots 产出含 injectedfield_path(与 SQL 版对齐)。
  • 暂无其他额外测试建议。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

构建 Session / Memory 多后端回放一致性测试框架

3 participants