Skip to content

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

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

feat: Session/Memory 多后端回放一致性测试框架 (issue #89)#178
wzxsdf wants to merge 10 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/session-memory-replay-consistency-design.md
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 版对齐)。
  • 暂无其他额外测试建议。

…+ compared_backends 排除参考后端

- test_replay_injections.py: Redis state_value 端到端用例 before/after 快照补 state=dict(...),
  此前只取 events 导致 app_state hash 注入不可见(与 SQL 版 _read_sql_snapshot 对齐)
- report.py: _compared_backends 加 reference_backend 参数并排除,避免参考后端 in_memory
  被列入 compared_backends(设计 §4.8,E2E statuses 含 in_memory 触发)
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

针对 summary_create (3 个事件,keep_recent=10):keep_recent_count(10) >= len(events)(3)insert_index = len(events) = 3selected_events = events[0:3] = 所有 3 个事件。非空。因此返回 (3 个事件, 3)。随后 _compress_session_to_summary 返回摘要文本。因此创建了摘要事件,events[:] = [summary_event] + events[3:] = [summary_event] (因为 events[3:] 为空)。因此压缩后,session.events = [summary_event]historical_events 获取了原始 3 个事件 (因为 store_historical_events=True)。随后 update_session 进行持久化。所以 got.events = [摘要事件],historical_events = 3 个原始事件。摘要缓存中存储了摘要。很好。

因此在 replay_case 的快照中:events=[summary_event], historical_events=[3 个原始事件], summary={"current":{text, version:1, ...}}。对于 summary_create 测试注入 (summary_affiliation),它将 session_id 设置为 "wrong-session"。已检测到。很好。

现在,这会在 in_memorysqlite 之间产生已知偏差 (KNOWN_DRIFT) 吗?只有 summary_updatesummary_truncation 属于已知偏差 (KNOWN_DRIFT)。summary_create 不在已知偏差中。因此 summary_createin_memorysqlite 之间应该匹配。报告显示其为匹配 (matched)。很好。

好了,我很有信心。让我重新考虑一下 summary_create 的比较是否成立。in_memory: events=[summary_event], historical_events=[3 个原始事件]sqlite: 相同。归一化器 (normalizer) 对 eventshistorical_events 的易变键进行了归一化。summary_event 包含时间戳 (归一化)、id (归一化)。两者应该匹配。很好。

现在让我结束。最终发现:陈旧报告 (建议)。没有 Critical/Warning 级别的问题。让我再重新考虑一下陈旧报告是否值得作为一个 Warning。它不会阻塞 CI。我将其定为建议。

其实,让我再重新考虑一下是否漏掉了实际的 Warning 级别问题。让我重新阅读 harness.py 中的内存断言:

if case_involves_memory and not memory_results:
    raise AssertionError(...)

如果 memory_search 没有返回结果,但这是合理的(例如,查询不匹配),这会抛出异常。对于 memory_preference ("安静" 匹配 "安静") 和 memory_fact_update ("上海" 匹配 "上海"),它们是匹配的。所以没有异常。但是如果 SDK 的 search 行为在不同后端间存在差异,该断言可能会产生误报。这是有意的安全机制。不是一个 bug。

让我再重新考虑一下:case_involves_memory 检查 memory_store/memory_search 操作。对于这两个内存相关的情况,两者都存在。memory_results 是在 memory_search 时填充的。如果 memory_store 执行了存储,但 search 没有返回任何内容(查询不匹配),断言就会触发。但查询是设计来匹配的。所以没问题。但这是一个脆弱的测试设计——如果 SDK 的分词逻辑发生变化,断言会中断。不过不在本次范围内。

好了,最终结论。让我再快速验证一下没有导致失败的测试,检查 _find 是否返回了用例 (cases) —— load_cases(CASES_DIR) 读取 jsonl 文件。10 个用例。很好。

让我定稿。我将报告陈旧报告不一致的问题作为建议,并说明没有阻塞问题。

等等,其实——让我再重新考虑一下陈旧的 compared_backends 是否可能表明测试正在用 ["in_memory","sqlite"] 生成报告,这意味着 _compared_backends 被传递了不同的状态。在 test_all_cases_cross_backend 中,statuses 来自 enabled_backends() = [in_memory(match), sqlite(match), redis(skipped)]。reference_backend="in_memory" (reference.name)。_compared_backends(statuses, case_results, "in_memory") → 排除 in_memory 和被跳过的 redis["sqlite"]。所以代码生成的是 ["sqlite"]。提交的 ["in_memory","sqlite"] 肯定是陈旧的。确认。

这是一个可验证的不一致之处。我将其报告为建议 (维护性)。

其实,重新考虑一下——既然设计文档明确说明 (§4.8) 参考后端不应出现在比较候选中,且代码也正确执行了这一点,但已提交的产物却违反了这一点,这可能会误导审查者认为代码包含了 in_memory。这是一个真正的维护性问题。我将其标记为建议,因为测试本身可以通过 (测试只检查 "sqlite" in)。

让我写下最终的审查意见。## 发现的问题

💡 Suggestion

  • tests/sessions/session_memory_summary_diff_report.json:1945-1948:提交的 diff 报告 compared_backends["in_memory","sqlite"],但 tests/sessions/replay/report.py:1756_compared_backends 会显式排除参考后端 reference_backend,实际运行产出应为 ["sqlite"]。该提交产物是旧版代码生成的陈旧快照,与当前代码逻辑及设计文档 §4.8「参考后端不应出现在已比较候选」自相矛盾,易误导 review。建议重新跑一次 test_all_cases_cross_backend 覆盖该 JSON(或将其移出 git 改为运行时产物),使提交产物与代码一致。

总结

未发现明显阻塞问题。框架内部逻辑(归一化、比较器、allowed_diff 治理、summary 三类检测、SQL/Redis 端到端注入路径)与 SDK 上下文核对一致:state_overwrite 用例虽写裸键进 session state 列,但 create_session 会在 app_states 建空行(state={} 序列化为 "{}" 真值),故 inject_sql_diff 的 state_value 分支能正常注入并检出,不构成 CI 失败。唯一可验证的不一致是提交的 diff 报告 JSON 与代码实际输出不符(Suggestion 级)。

测试建议

暂无额外测试建议;现有单元/注入/E2E 覆盖已较完整,且 SQL state_value 端到端用例已覆盖 app_states 表注入与 app: 前缀合并路径。

@@ -0,0 +1,435 @@
# Session / Memory / Summary 多后端回放一致性测试框架 — 设计文档

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

文件名称不要带日期,直接取名为design

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已删除设计文档文件名日期前缀,同时检查暂无其他文件名带日期前缀

- 重命名 2026-07-13-session-memory-replay-consistency-design.md → session-memory-replay-consistency-design.md
- 更新 README.md、IMPLEMENTATION_PLAN.md、自身目录树中的 4 处引用
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

我已经了解了所有背景信息。现在让我来写审查报告。

发现的问题

🚨 Critical

  • tests/sessions/replay/injectors.py:95-106tests/sessions/replay/injectors.py:139-141:端到端 state_value 注入改错了状态桶,测试通过属于误报
    • state_overwrite case 的 state_delta 全是无前缀键(counter/flag),属 session 作用域状态,SQL 存在 sessions.state 列、Redis 存在 session JSON 里;但 inject_sql_diff(state_value) 改的是 app_states 表、inject_redis_diff(state_value) 改的是 app_state:{app} hash。注入的 injected 键经 get_session 合并后会带上 app: 前缀(app:injected),测试仅断言 "injected" in field_path 即通过 —— 实际并未验证 session 作用域 state 的漂移感知,验收第 2 条的「100% 检出」在此 case 是假阳性。应改为直接 UPDATE sessions.state(SQL)或改写 session JSON 中对应键(Redis),或选用带 app:/user: 前缀的 state case。

⚠️ Warning

  • tests/sessions/replay/backends.py:83enabled_backends(tmp_path: Optional[str] = None, ) 带尾随逗号 + tmp_path 形参从未被 test_replay_consistency.py 调用传入

    • E2E 用 enabled_backends() 无参调用,tmp_path 始终为 None,SQL 走 sqlite:///:memory:;而「轻量模式持久化对比」本意是用文件 DB(否则 :memory: 在跨 service 实例间无法共享,端到端注入测试另起 sqlite_backend(db_url) 才规避了这点)。该参数形同虚设,易误导后续维护者以为 E2E 在用文件库。建议要么在 E2E 传入 tmp_path,要么删掉该参数。
  • tests/sessions/replay/backends.py:77-80redis_backendis_async=True 创建 service,但 SDK 的 RedisStorageis_async=True 时走 AsyncConnectionPool/AsyncRedis

    • test_replay_injections.py 的 Redis 测试在 TRPC_REPLAY_REDIS_URL 未设置时 pytest.skip,不会在轻量 CI 执行;但一旦有人设了该 env,redis.from_url(...) 的同步客户端(inject_redis_diff)与 service 的异步客户端指向同一库尚可,关键是 RedisSessionService(is_async=True)get_session 等都是 async 且依赖事件循环内 await —— 与 harness 的 await replay_case 一致,无阻塞问题。真正风险在 inject_redis_diff 用同步 redis.from_url 直连,若 SDK 使用 asyncio Redis(默认 decode_responses 未显式设置),返回 bytes 会导致 json.loads(raw) 报错。注释声称「decode_responses=True 与 SDK 一致」,但 SDK 的 RedisStorage 创建 pool 时并未传 decode_responses(见 trpc_agent_sdk/storage/_redis.py:111-113 只透传 **kwargs,而 redis_backend 未传该参数),两者并不一致。建议让注入器复用 SDK 的 storage 或至少确认 SDK 侧 decode 设置。
  • tests/sessions/replay/report.py:47-58_roll_up_status 对混合 skipped + match 的组合会误判为 match

    • 当一个 case 在某候选后端 skipped、另一候选 match 时,statuses = {"skipped","match"},既非含 mismatch、非纯 skipped、非纯 not_applicable,落到末行返回 "match",把含跳过的 case 计入「一致」。轻量模式只有 in_memory+sqlite 通常不触发,但 Redis skip + sqlite match 的场景会误报 match。建议显式处理含 skipped 的混合态(如返回 mismatch 或新增 partial 态)。
  • tests/sessions/replay/allowed_diff.py:72-80tests/sessions/test_replay_consistency.pycheck_governance 仅在单测内被调用,E2E 主流程未接入

    • 治理(无 reason / 超条数 / 超占比 抛错)只对 allowed_diff 规则生效,但 test_all_cases_cross_backend 比较前从不调用 check_governance,即便未来某 case 的 allowed_diff 越界也不会失败。当前 cases.jsonl 均为空 allowed_diff,故无即时风险;但治理形同虚设。建议在 E2E 循环内对每个 case 调一次 check_governance
  • tests/sessions/replay/injectors.py:34-45:快照层 event_author / event_text 注入直接用 snap.events[0],未判空 events

    • event_authorif snap.events: 保护,但 event_textsnap.events[0].get(...)(有保护)后改值;若 events[0]content/parts 则静默不注入,_detected 会返回 False 导致 test_all_eight_kinds_detected 失败 —— 但因 plan 里 event_text 挂在 single_turn(有 content),实际不触发。属边界缺陷:对结构不匹配的快照会漏注入且不报错。建议注入失败时显式 raise,避免「静默跳过→漏检」。

💡 Suggestion

  • tests/sessions/replay/injectors.py:37-40event_text 注入只改 parts[0].text,若首 part 非文本 part(如 function_call)则语义不符
    • 维护性影响:注入 kind 与 case 的对应关系散落在 test_replay_injections.py_CASE_INJECTION / plan 两处,且依赖快照结构假设。可考虑在 inject_snapshot_diff 内对结构不匹配直接 raise,让 kind↔case 绑定更显式。

总结

整体风险中等:测试框架设计完整、确定性策略合理,但端到端 state_value 注入改错了状态桶(Critical,验收结论为假阳性),且 redis_backenddecode_responses 一致性、_roll_up_status 对 skipped 混合态的处理、check_governance 未接入 E2E 等存在边界与集成缺口,建议修复后再合入。

测试建议

  • 补一个 state_overwrite 的端到端注入断言:注入后读回的 state 应在 session 作用域键上出现漂移(而非 app: 前缀键),以真正验证 session state 漂移感知。
  • 补一个 Redis skip + sqlite match 的 _roll_up_status 单测,确认混合态不被误判为 match

Critical(假阳性修复):
- injectors.py: state_value 注入从 app_states/app_state hash 改为 sessions.state/session JSON .state 字段
  修复前注入 app 作用域(带 app: 前缀),测试断言 "injected" in field_path 误命中
  修复后注入 session 作用域(state_overwrite case 的 state_delta 无前缀键属 session 作用域)
  验收第 2 条「100% 检出」不再假阳性

Warning 1(enabled_backends tmp_path 形同虚设):
- test_replay_consistency.py: E2E 传入 tmp_path 使用文件库(而非 sqlite:///:memory:)
  跨 service 实例可共享状态,符合持久化对比设计意图

Warning 2(_roll_up_status 混合态误判):
- report.py: 同时含 skipped 和 match 时返回 mismatch(保守策略)
  修复前 statuses={"skipped","match"} 返回 match,把含跳过的 case 计入「一致」

Warning 3(check_governance 未接入 E2E):
- test_replay_consistency.py: E2E 循环内对每个 case 调用治理检查
  防止 allowed_diff 规则越界(超条数/超占比/无 reason)

Warning 4(event_text 注入边界检查):
- injectors.py: events/content/parts 缺失时显式抛 AssertionError
  修复前静默跳过导致漏检且不报错

测试: 32 passed + 2 skipped(Redis 无 URL),yapf+flake8 干净
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

session_ref 已在 target 查找中使用,但没有任何测试用例使用它(用于跨会话)。这不是 Bug,而是为未来预留的。

我来仔细核对一下内存断言的问题,看看它是否真的会对正向路径造成影响,以最终确定结论。实际上,它今天可以通过,所以“警告”是关于脆弱性/耦合性,而不是主动失败的。我会将其定为警告。

我来完成最终总结。

发现的问题

🚨 Critical

无。

⚠️ Warning

  • tests/sessions/replay/harness.py:185-191replay_case 内嵌的 memory 断言与正向测试路径耦合且理由误导
    • 该断言「case 涉及 memory 操作但 memory_results 为空就抛 AssertionError」放在共享 harness 中,注释理由是「避免 test_replay_injections.py 的 memory_content 注入被跳过→漏检」,但 replay_case 同时被正向 E2E(test_all_cases_cross_backend)调用——正向路径根本不注入,一旦某条 memory case 的 query 与事件文本不再命中(中文按单字匹配,case 文本/查询任意改动即可能失效),正向一致性测试会以「注入漏检」的误导信息失败。建议把该守卫下沉到注入测试侧(或仅在注入测试的 memory 路径启用),正向路径不应承担注入语义的断言。
...
# 修复:如果 case 涉及 memory 操作但 memory 为空,抛出断言避免注入漏检
if case_involves_memory and not memory_results:
    raise AssertionError(
        f"Case {case.case_id} 涉及 memory 操作...导致 test_replay_injections.py 的 memory_content 注入被跳过 → 漏检。"
    )
...
  • tests/sessions/replay/injectors.py:88-95:SQL event_author 注入 UPDATE 仅按 session_id 过滤,未限定 app_name/user_id
    • events 表主键是 (app_name, user_id, session_id, id),仅按 session_id 过滤在多 app/user 共库时会误改他行。当前测试单 session 不触发,但作为可复用 harness 的注入工具存在误注入风险;建议补 app_name/user_id 条件,与 state_value 分支保持一致。

💡 Suggestion

  • tests/sessions/replay/harness.py:69ReplayOp.function_response_id 字段声明后无任何写入/读取点(_build_event 未使用),属死字段,建议删除或接入 _build_event 以免误导后续 case 编写者。
  • tests/sessions/test_replay_injections.py:33-51:端到端 SQL/Redis 的 before 快照(_read_sql_snapshot / Redis 构造)只取 events(state 测试另补 state),省略 historical_events/memory/summary;若注入意外波及其他字段将无法检出。建议复用 replay_case 的完整快照读取,保持注入前后快照结构一致。

总结

整体风险较低:新增为测试框架(仅 tests/sessions/),无生产代码改动,SQL/Redis 注入均用参数化语句、无注入或凭证泄露风险,正向/负向测试与提交的报告产物自洽。无 Critical;2 项 Warning 为 harness 内嵌断言的正/负向耦合及 SQL 注入过滤条件偏宽,均可在当前 case 下通过但存在脆弱性/误注入风险,建议修复。

测试建议

  • 补一条「memory case 的 query 与事件文本不命中」的正向用例,验证 replay_case 不会因 memory 守卫在非注入路径误抛(或在修复后验证守卫已下沉到注入测试)。
  • inject_sql_diff/inject_redis_diff 增加多 app/user 共库场景,验证 event_author 注入只改目标 session 行。

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