From c459da123976e6c55e8f59307fc78a1a85d4708f Mon Sep 17 00:00:00 2001 From: mingri31164 <3116430062@qq.com> Date: Sun, 26 Jul 2026 00:38:54 +0800 Subject: [PATCH] feat: add session memory replay consistency harness --- session_memory_summary_diff_report.json | 3457 +++++++++++++++++ tests/sessions/replay_cases/README.md | 31 + .../replay_cases/session_memory_summary.jsonl | 10 + tests/sessions/replay_harness.py | 1005 +++++ tests/sessions/test_replay_consistency.py | 177 + tests/storage/test_redis.py | 43 + tests/storage/test_redis_cluster_storage.py | 12 +- trpc_agent_sdk/storage/_redis.py | 58 +- trpc_agent_sdk/storage/_redis_cluster.py | 2 +- 9 files changed, 4780 insertions(+), 15 deletions(-) create mode 100644 session_memory_summary_diff_report.json create mode 100644 tests/sessions/replay_cases/README.md create mode 100644 tests/sessions/replay_cases/session_memory_summary.jsonl create mode 100644 tests/sessions/replay_harness.py create mode 100644 tests/sessions/test_replay_consistency.py diff --git a/session_memory_summary_diff_report.json b/session_memory_summary_diff_report.json new file mode 100644 index 000000000..50bc51394 --- /dev/null +++ b/session_memory_summary_diff_report.json @@ -0,0 +1,3457 @@ +{ + "allowed_diff_rules": [ + { + "path": "$.memory.*", + "reason": "Keyword-memory ranking order is backend-specific; entry content and count must still match.", + "scope": "order_only" + }, + { + "path": "$.recovery_raw[*].mechanism", + "reason": "A backend may reject a duplicate transactionally or require compensating cleanup.", + "scope": "mechanism_only" + } + ], + "backends": [ + "inmemory", + "sqlite" + ], + "cases": [ + { + "allowed_diffs": [], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 2, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Hello, replay harness." + } + ], + "role": "user" + }, + "id": "single-user-1", + "invocation_id": "invocation-single-user-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Hello. How can I help?" + } + ], + "role": "model" + }, + "id": "single-agent-1", + "invocation_id": "invocation-single-agent-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 2, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Hello, replay harness." + } + ], + "role": "user" + }, + "id": "single-user-1", + "invocation_id": "invocation-single-user-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Hello. How can I help?" + } + ], + "role": "model" + }, + "id": "single-agent-1", + "invocation_id": "invocation-single-agent-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + } + }, + "case_id": "single_turn", + "description": "One user message followed by one assistant response.", + "differences": [], + "session_id": "session-single", + "status": "passed" + }, + { + "allowed_diffs": [], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 6, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "My name is Ada." + } + ], + "role": "user" + }, + "id": "multi-user-1", + "invocation_id": "invocation-multi-user-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Nice to meet you, Ada." + } + ], + "role": "model" + }, + "id": "multi-agent-1", + "invocation_id": "invocation-multi-agent-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "What is my name?" + } + ], + "role": "user" + }, + "id": "multi-user-2", + "invocation_id": "invocation-multi-user-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Your name is Ada." + } + ], + "role": "model" + }, + "id": "multi-agent-2", + "invocation_id": "invocation-multi-agent-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Remember that." + } + ], + "role": "user" + }, + "id": "multi-user-3", + "invocation_id": "invocation-multi-user-3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "I will remember it." + } + ], + "role": "model" + }, + "id": "multi-agent-3", + "invocation_id": "invocation-multi-agent-3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 6, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "My name is Ada." + } + ], + "role": "user" + }, + "id": "multi-user-1", + "invocation_id": "invocation-multi-user-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Nice to meet you, Ada." + } + ], + "role": "model" + }, + "id": "multi-agent-1", + "invocation_id": "invocation-multi-agent-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "What is my name?" + } + ], + "role": "user" + }, + "id": "multi-user-2", + "invocation_id": "invocation-multi-user-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Your name is Ada." + } + ], + "role": "model" + }, + "id": "multi-agent-2", + "invocation_id": "invocation-multi-agent-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Remember that." + } + ], + "role": "user" + }, + "id": "multi-user-3", + "invocation_id": "invocation-multi-user-3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "I will remember it." + } + ], + "role": "model" + }, + "id": "multi-agent-3", + "invocation_id": "invocation-multi-agent-3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + } + }, + "case_id": "multi_turn", + "description": "Three complete user and assistant turns preserve event order.", + "differences": [], + "session_id": "session-multi", + "status": "passed" + }, + { + "allowed_diffs": [], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 4, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "What is the weather in Shenzhen?" + } + ], + "role": "user" + }, + "id": "tool-user-1", + "invocation_id": "invocation-tool-user-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "function_call": { + "args": { + "city": "Shenzhen", + "unit": "celsius" + }, + "id": "call-weather-1", + "name": "get_weather" + } + } + ], + "role": "model" + }, + "id": "tool-call-1", + "invocation_id": "invocation-tool-call-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "tool", + "content": { + "parts": [ + { + "function_response": { + "id": "call-weather-1", + "name": "get_weather", + "response": { + "condition": "sunny", + "temperature": 31 + } + } + } + ], + "role": "function" + }, + "id": "tool-response-1", + "invocation_id": "invocation-tool-response-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "It is 31 C and sunny in Shenzhen." + } + ], + "role": "model" + }, + "id": "tool-agent-1", + "invocation_id": "invocation-tool-agent-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 4, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "What is the weather in Shenzhen?" + } + ], + "role": "user" + }, + "id": "tool-user-1", + "invocation_id": "invocation-tool-user-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "function_call": { + "args": { + "city": "Shenzhen", + "unit": "celsius" + }, + "id": "call-weather-1", + "name": "get_weather" + } + } + ], + "role": "model" + }, + "id": "tool-call-1", + "invocation_id": "invocation-tool-call-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "tool", + "content": { + "parts": [ + { + "function_response": { + "id": "call-weather-1", + "name": "get_weather", + "response": { + "condition": "sunny", + "temperature": 31 + } + } + } + ], + "role": "function" + }, + "id": "tool-response-1", + "invocation_id": "invocation-tool-response-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "It is 31 C and sunny in Shenzhen." + } + ], + "role": "model" + }, + "id": "tool-agent-1", + "invocation_id": "invocation-tool-agent-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + } + }, + "case_id": "tool_call", + "description": "A function call and matching function response round trip through storage.", + "differences": [], + "session_id": "session-tool", + "status": "passed" + }, + { + "allowed_diffs": [], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 2, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": { + "counter": 1, + "theme": "dark" + } + }, + "author": "system", + "id": "state-update-1", + "invocation_id": "invocation-state-update-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": { + "app:policy": "v2", + "counter": 2, + "theme": "solarized", + "user:locale": "fr" + } + }, + "author": "system", + "id": "state-update-2", + "invocation_id": "invocation-state-update-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [], + "state": { + "app:policy": "v2", + "counter": 2, + "theme": "solarized", + "user:locale": "fr" + }, + "summary": { + "current": null, + "revisions": [] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 2, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": { + "counter": 1, + "theme": "dark" + } + }, + "author": "system", + "id": "state-update-1", + "invocation_id": "invocation-state-update-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": { + "app:policy": "v2", + "counter": 2, + "theme": "solarized", + "user:locale": "fr" + } + }, + "author": "system", + "id": "state-update-2", + "invocation_id": "invocation-state-update-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [], + "state": { + "app:policy": "v2", + "counter": 2, + "theme": "solarized", + "user:locale": "fr" + }, + "summary": { + "current": null, + "revisions": [] + } + } + } + }, + "case_id": "state_overwrite", + "description": "Session, user, and app state updates use last-write-wins semantics.", + "differences": [], + "session_id": "session-state", + "status": "passed" + }, + { + "allowed_diffs": [ + { + "allowed": true, + "backend": "sqlite", + "backend_value": [ + { + "author": "assistant", + "content": { + "parts": [ + { + "text": "I will remember your tea preference." + } + ], + "role": "model" + }, + "timestamp": "" + }, + { + "author": "user", + "content": { + "parts": [ + { + "text": "I prefer green tea without sugar." + } + ], + "role": "user" + }, + "timestamp": "" + } + ], + "case_id": "memory_write_read", + "domain": "memory", + "event_index": null, + "explanation": "Keyword-memory ranking order is backend-specific; entry content and count must still match.", + "path": "$.memory.tea_preference", + "reference_backend": "inmemory", + "reference_value": [ + { + "author": "user", + "content": { + "parts": [ + { + "text": "I prefer green tea without sugar." + } + ], + "role": "user" + }, + "timestamp": "" + }, + { + "author": "assistant", + "content": { + "parts": [ + { + "text": "I will remember your tea preference." + } + ], + "role": "model" + }, + "timestamp": "" + } + ], + "session_id": "session-memory", + "summary_id": null + }, + { + "allowed": true, + "backend": "sqlite", + "backend_value": [ + { + "author": "assistant", + "content": { + "parts": [ + { + "text": "Your timezone preference is saved." + } + ], + "role": "model" + }, + "timestamp": "" + }, + { + "author": "user", + "content": { + "parts": [ + { + "text": "My timezone is Asia Shanghai." + } + ], + "role": "user" + }, + "timestamp": "" + } + ], + "case_id": "memory_write_read", + "domain": "memory", + "event_index": null, + "explanation": "Keyword-memory ranking order is backend-specific; entry content and count must still match.", + "path": "$.memory.timezone_fact", + "reference_backend": "inmemory", + "reference_value": [ + { + "author": "user", + "content": { + "parts": [ + { + "text": "My timezone is Asia Shanghai." + } + ], + "role": "user" + }, + "timestamp": "" + }, + { + "author": "assistant", + "content": { + "parts": [ + { + "text": "Your timezone preference is saved." + } + ], + "role": "model" + }, + "timestamp": "" + } + ], + "session_id": "session-memory", + "summary_id": null + } + ], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 7, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "I prefer green tea without sugar." + } + ], + "role": "user" + }, + "id": "memory-user-1", + "invocation_id": "invocation-memory-user-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "I will remember your tea preference." + } + ], + "role": "model" + }, + "id": "memory-agent-1", + "invocation_id": "invocation-memory-agent-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "My timezone is Asia Shanghai." + } + ], + "role": "user" + }, + "id": "memory-user-2", + "invocation_id": "invocation-memory-user-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Your timezone preference is saved." + } + ], + "role": "model" + }, + "id": "memory-agent-2", + "invocation_id": "invocation-memory-agent-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": { + "tea_preference": [ + { + "author": "assistant", + "content": { + "parts": [ + { + "text": "I will remember your tea preference." + } + ], + "role": "model" + }, + "timestamp": "" + }, + { + "author": "user", + "content": { + "parts": [ + { + "text": "I prefer green tea without sugar." + } + ], + "role": "user" + }, + "timestamp": "" + } + ], + "timezone_fact": [ + { + "author": "assistant", + "content": { + "parts": [ + { + "text": "Your timezone preference is saved." + } + ], + "role": "model" + }, + "timestamp": "" + }, + { + "author": "user", + "content": { + "parts": [ + { + "text": "My timezone is Asia Shanghai." + } + ], + "role": "user" + }, + "timestamp": "" + } + ] + }, + "operation_audit": [], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 7, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "I prefer green tea without sugar." + } + ], + "role": "user" + }, + "id": "memory-user-1", + "invocation_id": "invocation-memory-user-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "I will remember your tea preference." + } + ], + "role": "model" + }, + "id": "memory-agent-1", + "invocation_id": "invocation-memory-agent-1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "My timezone is Asia Shanghai." + } + ], + "role": "user" + }, + "id": "memory-user-2", + "invocation_id": "invocation-memory-user-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Your timezone preference is saved." + } + ], + "role": "model" + }, + "id": "memory-agent-2", + "invocation_id": "invocation-memory-agent-2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": { + "tea_preference": [ + { + "author": "assistant", + "content": { + "parts": [ + { + "text": "I will remember your tea preference." + } + ], + "role": "model" + }, + "timestamp": "" + }, + { + "author": "user", + "content": { + "parts": [ + { + "text": "I prefer green tea without sugar." + } + ], + "role": "user" + }, + "timestamp": "" + } + ], + "timezone_fact": [ + { + "author": "assistant", + "content": { + "parts": [ + { + "text": "Your timezone preference is saved." + } + ], + "role": "model" + }, + "timestamp": "" + }, + { + "author": "user", + "content": { + "parts": [ + { + "text": "My timezone is Asia Shanghai." + } + ], + "role": "user" + }, + "timestamp": "" + } + ] + }, + "operation_audit": [], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + } + }, + "case_id": "memory_write_read", + "description": "Preference and fact memories are stored and retrieved by keyword.", + "differences": [], + "session_id": "session-memory", + "status": "passed" + }, + { + "allowed_diffs": [], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 10, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "the release needs tests, changelog, and security review. lin owns the release." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-summary-update", + "summary_id": "summary-update-v2", + "supersedes": "summary-update-v1", + "version": 2 + } + }, + "id": "summary-update-v2", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 2, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "The release owner is Lin." + } + ], + "role": "user" + }, + "id": "summary-u4", + "invocation_id": "invocation-summary-u4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Lin is the release owner." + } + ], + "role": "model" + }, + "id": "summary-a4", + "invocation_id": "invocation-summary-a4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Plan a release checklist." + } + ], + "role": "user" + }, + "id": "summary-u1", + "invocation_id": "invocation-summary-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Start with tests and changelog." + } + ], + "role": "model" + }, + "id": "summary-a1", + "invocation_id": "invocation-summary-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Add a security review." + } + ], + "role": "user" + }, + "id": "summary-u2", + "invocation_id": "invocation-summary-u2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Security review added." + } + ], + "role": "model" + }, + "id": "summary-a2", + "invocation_id": "invocation-summary-a2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "the release checklist includes tests, changelog, and a security review." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-summary-update", + "summary_id": "summary-update-v1", + "supersedes": null, + "version": 1 + } + }, + "id": "summary-update-v1", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 1, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Keep the latest decisions." + } + ], + "role": "user" + }, + "id": "summary-u3", + "invocation_id": "invocation-summary-u3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "The decisions are retained." + } + ], + "role": "model" + }, + "id": "summary-a3", + "invocation_id": "invocation-summary-a3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": { + "anchor_count": 1, + "anchor_text": "the release needs tests, changelog, and security review. lin owns the release.", + "compressed_event_count": 3, + "original_event_count": 5, + "session_id": "session-summary-update", + "summary_id": "summary-update-v2", + "supersedes": "summary-update-v1", + "text": "the release needs tests, changelog, and security review. lin owns the release.", + "updated_at": "", + "version": 2 + }, + "revisions": [ + { + "anchor_count": 1, + "anchor_text": "the release checklist includes tests, changelog, and a security review.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-summary-update", + "summary_id": "summary-update-v1", + "supersedes": null, + "text": "the release checklist includes tests, changelog, and a security review.", + "updated_at": "", + "updated_at_order": 1, + "version": 1 + }, + { + "anchor_count": 1, + "anchor_text": "the release needs tests, changelog, and security review. lin owns the release.", + "compressed_event_count": 3, + "original_event_count": 5, + "session_id": "session-summary-update", + "summary_id": "summary-update-v2", + "supersedes": "summary-update-v1", + "text": "the release needs tests, changelog, and security review. lin owns the release.", + "updated_at": "", + "updated_at_order": 2, + "version": 2 + } + ] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 10, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "the release needs tests, changelog, and security review. lin owns the release." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-summary-update", + "summary_id": "summary-update-v2", + "supersedes": "summary-update-v1", + "version": 2 + } + }, + "id": "summary-update-v2", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 2, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "The release owner is Lin." + } + ], + "role": "user" + }, + "id": "summary-u4", + "invocation_id": "invocation-summary-u4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Lin is the release owner." + } + ], + "role": "model" + }, + "id": "summary-a4", + "invocation_id": "invocation-summary-a4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Plan a release checklist." + } + ], + "role": "user" + }, + "id": "summary-u1", + "invocation_id": "invocation-summary-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Start with tests and changelog." + } + ], + "role": "model" + }, + "id": "summary-a1", + "invocation_id": "invocation-summary-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Add a security review." + } + ], + "role": "user" + }, + "id": "summary-u2", + "invocation_id": "invocation-summary-u2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Security review added." + } + ], + "role": "model" + }, + "id": "summary-a2", + "invocation_id": "invocation-summary-a2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "the release checklist includes tests, changelog, and a security review." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-summary-update", + "summary_id": "summary-update-v1", + "supersedes": null, + "version": 1 + } + }, + "id": "summary-update-v1", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 1, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Keep the latest decisions." + } + ], + "role": "user" + }, + "id": "summary-u3", + "invocation_id": "invocation-summary-u3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "The decisions are retained." + } + ], + "role": "model" + }, + "id": "summary-a3", + "invocation_id": "invocation-summary-a3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": { + "anchor_count": 1, + "anchor_text": "the release needs tests, changelog, and security review. lin owns the release.", + "compressed_event_count": 3, + "original_event_count": 5, + "session_id": "session-summary-update", + "summary_id": "summary-update-v2", + "supersedes": "summary-update-v1", + "text": "the release needs tests, changelog, and security review. lin owns the release.", + "updated_at": "", + "version": 2 + }, + "revisions": [ + { + "anchor_count": 1, + "anchor_text": "the release checklist includes tests, changelog, and a security review.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-summary-update", + "summary_id": "summary-update-v1", + "supersedes": null, + "text": "the release checklist includes tests, changelog, and a security review.", + "updated_at": "", + "updated_at_order": 1, + "version": 1 + }, + { + "anchor_count": 1, + "anchor_text": "the release needs tests, changelog, and security review. lin owns the release.", + "compressed_event_count": 3, + "original_event_count": 5, + "session_id": "session-summary-update", + "summary_id": "summary-update-v2", + "supersedes": "summary-update-v1", + "text": "the release needs tests, changelog, and security review. lin owns the release.", + "updated_at": "", + "updated_at_order": 2, + "version": 2 + } + ] + } + } + } + }, + "case_id": "summary_create_update", + "description": "A second summary supersedes the first revision without losing ownership metadata.", + "differences": [], + "session_id": "session-summary-update", + "status": "passed" + }, + { + "allowed_diffs": [], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 9, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "project orion targets friday." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-summary-truncate", + "summary_id": "summary-truncate-v1", + "supersedes": null, + "version": 1 + } + }, + "id": "summary-truncate-v1", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 1, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Keep two recent events." + } + ], + "role": "user" + }, + "id": "truncate-u3", + "invocation_id": "invocation-truncate-u3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Two recent events will remain." + } + ], + "role": "model" + }, + "id": "truncate-a3", + "invocation_id": "invocation-truncate-a3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Add a deployment rehearsal." + } + ], + "role": "user" + }, + "id": "truncate-u4", + "invocation_id": "invocation-truncate-u4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Deployment rehearsal added." + } + ], + "role": "model" + }, + "id": "truncate-a4", + "invocation_id": "invocation-truncate-a4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "The project codename is Orion." + } + ], + "role": "user" + }, + "id": "truncate-u1", + "invocation_id": "invocation-truncate-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Codename Orion noted." + } + ], + "role": "model" + }, + "id": "truncate-a1", + "invocation_id": "invocation-truncate-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "The target date is Friday." + } + ], + "role": "user" + }, + "id": "truncate-u2", + "invocation_id": "invocation-truncate-u2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Friday is the target date." + } + ], + "role": "model" + }, + "id": "truncate-a2", + "invocation_id": "invocation-truncate-a2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": { + "anchor_count": 1, + "anchor_text": "project orion targets friday.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-summary-truncate", + "summary_id": "summary-truncate-v1", + "supersedes": null, + "text": "project orion targets friday.", + "updated_at": "", + "version": 1 + }, + "revisions": [ + { + "anchor_count": 1, + "anchor_text": "project orion targets friday.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-summary-truncate", + "summary_id": "summary-truncate-v1", + "supersedes": null, + "text": "project orion targets friday.", + "updated_at": "", + "updated_at_order": 1, + "version": 1 + } + ] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 9, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "project orion targets friday." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-summary-truncate", + "summary_id": "summary-truncate-v1", + "supersedes": null, + "version": 1 + } + }, + "id": "summary-truncate-v1", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 1, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Keep two recent events." + } + ], + "role": "user" + }, + "id": "truncate-u3", + "invocation_id": "invocation-truncate-u3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Two recent events will remain." + } + ], + "role": "model" + }, + "id": "truncate-a3", + "invocation_id": "invocation-truncate-a3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Add a deployment rehearsal." + } + ], + "role": "user" + }, + "id": "truncate-u4", + "invocation_id": "invocation-truncate-u4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Deployment rehearsal added." + } + ], + "role": "model" + }, + "id": "truncate-a4", + "invocation_id": "invocation-truncate-a4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "The project codename is Orion." + } + ], + "role": "user" + }, + "id": "truncate-u1", + "invocation_id": "invocation-truncate-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Codename Orion noted." + } + ], + "role": "model" + }, + "id": "truncate-a1", + "invocation_id": "invocation-truncate-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "The target date is Friday." + } + ], + "role": "user" + }, + "id": "truncate-u2", + "invocation_id": "invocation-truncate-u2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Friday is the target date." + } + ], + "role": "model" + }, + "id": "truncate-a2", + "invocation_id": "invocation-truncate-a2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": { + "anchor_count": 1, + "anchor_text": "project orion targets friday.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-summary-truncate", + "summary_id": "summary-truncate-v1", + "supersedes": null, + "text": "project orion targets friday.", + "updated_at": "", + "version": 1 + }, + "revisions": [ + { + "anchor_count": 1, + "anchor_text": "project orion targets friday.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-summary-truncate", + "summary_id": "summary-truncate-v1", + "supersedes": null, + "text": "project orion targets friday.", + "updated_at": "", + "updated_at_order": 1, + "version": 1 + } + ] + } + } + } + }, + "case_id": "summary_event_truncation", + "description": "A summary anchor, retained events, and later events reconstruct compressed context.", + "differences": [], + "session_id": "session-summary-truncate", + "status": "passed" + }, + { + "allowed_diffs": [], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 5, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "user chose tea. next: brew." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-summary-semantic", + "summary_id": "summary-semantic-v1", + "supersedes": null, + "version": 1 + } + }, + "id": "summary-semantic-v1", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 1, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Brew it next." + } + ], + "role": "user" + }, + "id": "semantic-u2", + "invocation_id": "invocation-semantic-u2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Brewing is next." + } + ], + "role": "model" + }, + "id": "semantic-a2", + "invocation_id": "invocation-semantic-a2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "I chose tea." + } + ], + "role": "user" + }, + "id": "semantic-u1", + "invocation_id": "invocation-semantic-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Tea choice recorded." + } + ], + "role": "model" + }, + "id": "semantic-a1", + "invocation_id": "invocation-semantic-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": { + "anchor_count": 1, + "anchor_text": "user chose tea. next: brew.", + "compressed_event_count": 3, + "original_event_count": 4, + "session_id": "session-summary-semantic", + "summary_id": "summary-semantic-v1", + "supersedes": null, + "text": "user chose tea. next: brew.", + "updated_at": "", + "version": 1 + }, + "revisions": [ + { + "anchor_count": 1, + "anchor_text": "user chose tea. next: brew.", + "compressed_event_count": 3, + "original_event_count": 4, + "session_id": "session-summary-semantic", + "summary_id": "summary-semantic-v1", + "supersedes": null, + "text": "user chose tea. next: brew.", + "updated_at": "", + "updated_at_order": 1, + "version": 1 + } + ] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 5, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "user chose tea. next: brew." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-summary-semantic", + "summary_id": "summary-semantic-v1", + "supersedes": null, + "version": 1 + } + }, + "id": "summary-semantic-v1", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 1, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Brew it next." + } + ], + "role": "user" + }, + "id": "semantic-u2", + "invocation_id": "invocation-semantic-u2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Brewing is next." + } + ], + "role": "model" + }, + "id": "semantic-a2", + "invocation_id": "invocation-semantic-a2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "I chose tea." + } + ], + "role": "user" + }, + "id": "semantic-u1", + "invocation_id": "invocation-semantic-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Tea choice recorded." + } + ], + "role": "model" + }, + "id": "semantic-a1", + "invocation_id": "invocation-semantic-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "memory": {}, + "operation_audit": [], + "state": {}, + "summary": { + "current": { + "anchor_count": 1, + "anchor_text": "user chose tea. next: brew.", + "compressed_event_count": 3, + "original_event_count": 4, + "session_id": "session-summary-semantic", + "summary_id": "summary-semantic-v1", + "supersedes": null, + "text": "user chose tea. next: brew.", + "updated_at": "", + "version": 1 + }, + "revisions": [ + { + "anchor_count": 1, + "anchor_text": "user chose tea. next: brew.", + "compressed_event_count": 3, + "original_event_count": 4, + "session_id": "session-summary-semantic", + "summary_id": "summary-semantic-v1", + "supersedes": null, + "text": "user chose tea. next: brew.", + "updated_at": "", + "updated_at_order": 1, + "version": 1 + } + ] + } + } + } + }, + "case_id": "summary_semantic_normalization", + "description": "Summary comparison normalizes Unicode case and whitespace but preserves metadata.", + "differences": [], + "session_id": "session-summary-semantic", + "status": "passed" + }, + { + "allowed_diffs": [ + { + "allowed": true, + "backend": "sqlite", + "backend_value": [ + { + "append_error": "IntegrityError", + "kind": "duplicate_append", + "mechanism": "transactional_rejection", + "observed_duplicate_count": 1 + } + ], + "case_id": "duplicate_write_recovery", + "domain": "recovery", + "event_index": null, + "explanation": "A backend may reject a duplicate transactionally or require compensating cleanup.", + "path": "$.recovery_raw", + "reference_backend": "inmemory", + "reference_value": [ + { + "append_error": null, + "kind": "duplicate_append", + "mechanism": "compensating_deduplication", + "observed_duplicate_count": 2 + } + ], + "session_id": "session-duplicate", + "summary_id": null + } + ], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 3, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Run once." + } + ], + "role": "user" + }, + "id": "duplicate-u1", + "invocation_id": "invocation-duplicate-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Completed once." + } + ], + "role": "model" + }, + "id": "duplicate-a1", + "invocation_id": "invocation-duplicate-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [ + { + "final_event_count": 1, + "kind": "duplicate_append", + "recovered": true + } + ], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 3, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Run once." + } + ], + "role": "user" + }, + "id": "duplicate-u1", + "invocation_id": "invocation-duplicate-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Completed once." + } + ], + "role": "model" + }, + "id": "duplicate-a1", + "invocation_id": "invocation-duplicate-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [], + "memory": {}, + "operation_audit": [ + { + "final_event_count": 1, + "kind": "duplicate_append", + "recovered": true + } + ], + "state": {}, + "summary": { + "current": null, + "revisions": [] + } + } + } + }, + "case_id": "duplicate_write_recovery", + "description": "A retried event write is detected and recovered to one logical event.", + "differences": [], + "session_id": "session-duplicate", + "status": "passed" + }, + { + "allowed_diffs": [], + "backend_results": { + "inmemory": { + "error": null, + "invariant_failures": [], + "operation_count": 10, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "migration starts monday and requires backups." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-failed-summary", + "summary_id": "summary-failure-v1", + "supersedes": null, + "version": 1 + } + }, + "id": "summary-failure-v1", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 1, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Keep the newest turn." + } + ], + "role": "user" + }, + "id": "failure-u3", + "invocation_id": "invocation-failure-u3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Newest turn retained." + } + ], + "role": "model" + }, + "id": "failure-a3", + "invocation_id": "invocation-failure-a3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Notify operations." + } + ], + "role": "user" + }, + "id": "failure-u4", + "invocation_id": "invocation-failure-u4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Operations will be notified." + } + ], + "role": "model" + }, + "id": "failure-a4", + "invocation_id": "invocation-failure-a4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "The migration starts Monday." + } + ], + "role": "user" + }, + "id": "failure-u1", + "invocation_id": "invocation-failure-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Monday start recorded." + } + ], + "role": "model" + }, + "id": "failure-a1", + "invocation_id": "invocation-failure-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Backups are mandatory." + } + ], + "role": "user" + }, + "id": "failure-u2", + "invocation_id": "invocation-failure-u2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Backups are mandatory." + } + ], + "role": "model" + }, + "id": "failure-a2", + "invocation_id": "invocation-failure-a2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "memory": {}, + "operation_audit": [ + { + "final_event_count": 5, + "kind": "summary_update", + "recovered": true + } + ], + "state": {}, + "summary": { + "current": { + "anchor_count": 1, + "anchor_text": "migration starts monday and requires backups.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-failed-summary", + "summary_id": "summary-failure-v1", + "supersedes": null, + "text": "migration starts monday and requires backups.", + "updated_at": "", + "version": 1 + }, + "revisions": [ + { + "anchor_count": 1, + "anchor_text": "migration starts monday and requires backups.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-failed-summary", + "summary_id": "summary-failure-v1", + "supersedes": null, + "text": "migration starts monday and requires backups.", + "updated_at": "", + "updated_at_order": 1, + "version": 1 + } + ] + } + } + }, + "sqlite": { + "error": null, + "invariant_failures": [], + "operation_count": 10, + "snapshot": { + "events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "system", + "content": { + "parts": [ + { + "text": "migration starts monday and requires backups." + } + ], + "role": "user" + }, + "custom_metadata": { + "replay_summary": { + "session_id": "session-failed-summary", + "summary_id": "summary-failure-v1", + "supersedes": null, + "version": 1 + } + }, + "id": "summary-failure-v1", + "invocation_id": "summary", + "model_flags": 3, + "requires_completion": false, + "timestamp": "", + "version": 1, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Keep the newest turn." + } + ], + "role": "user" + }, + "id": "failure-u3", + "invocation_id": "invocation-failure-u3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Newest turn retained." + } + ], + "role": "model" + }, + "id": "failure-a3", + "invocation_id": "invocation-failure-a3", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Notify operations." + } + ], + "role": "user" + }, + "id": "failure-u4", + "invocation_id": "invocation-failure-u4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Operations will be notified." + } + ], + "role": "model" + }, + "id": "failure-a4", + "invocation_id": "invocation-failure-a4", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "historical_events": [ + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "The migration starts Monday." + } + ], + "role": "user" + }, + "id": "failure-u1", + "invocation_id": "invocation-failure-u1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Monday start recorded." + } + ], + "role": "model" + }, + "id": "failure-a1", + "invocation_id": "invocation-failure-a1", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "user", + "content": { + "parts": [ + { + "text": "Backups are mandatory." + } + ], + "role": "user" + }, + "id": "failure-u2", + "invocation_id": "invocation-failure-u2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + }, + { + "actions": { + "artifact_delta": {}, + "state_delta": {} + }, + "author": "assistant", + "content": { + "parts": [ + { + "text": "Backups are mandatory." + } + ], + "role": "model" + }, + "id": "failure-a2", + "invocation_id": "invocation-failure-a2", + "model_flags": 1, + "requires_completion": false, + "timestamp": "", + "version": 0, + "visible": true + } + ], + "memory": {}, + "operation_audit": [ + { + "final_event_count": 5, + "kind": "summary_update", + "recovered": true + } + ], + "state": {}, + "summary": { + "current": { + "anchor_count": 1, + "anchor_text": "migration starts monday and requires backups.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-failed-summary", + "summary_id": "summary-failure-v1", + "supersedes": null, + "text": "migration starts monday and requires backups.", + "updated_at": "", + "version": 1 + }, + "revisions": [ + { + "anchor_count": 1, + "anchor_text": "migration starts monday and requires backups.", + "compressed_event_count": 3, + "original_event_count": 6, + "session_id": "session-failed-summary", + "summary_id": "summary-failure-v1", + "supersedes": null, + "text": "migration starts monday and requires backups.", + "updated_at": "", + "updated_at_order": 1, + "version": 1 + } + ] + } + } + } + }, + "case_id": "failed_summary_recovery", + "description": "A failure after summary replacement restores events, state, and the prior summary revision.", + "differences": [], + "session_id": "session-failed-summary", + "status": "passed" + } + ], + "generated_at": "2026-07-25T16:36:37.899330+00:00", + "mode": "lightweight-persistent", + "normalization_rules": [ + { + "path": "$.events[*].timestamp", + "reason": "Wall-clock timestamps are non-business metadata.", + "strategy": "replace_with_" + }, + { + "path": "$.events[*].id", + "reason": "Backends and summary generation may allocate different physical IDs.", + "strategy": "logical_replay_id_or_stable_index" + }, + { + "path": "$.summary.*.text", + "reason": "Summary content is compared semantically for formatting-only differences.", + "strategy": "unicode_nfkc_casefold_and_whitespace_collapse" + }, + { + "path": "$.memory.*", + "reason": "MemoryService does not define result ordering for equal keyword matches.", + "strategy": "sort_by_normalized_content_author" + }, + { + "path": "$.*", + "reason": "Serialized object key order is not business data.", + "strategy": "structural_json_comparison" + } + ], + "reference_backend": "inmemory", + "report_sha256": "a374ad6a82dfce94852ccc588ed064dec1a0af0c89bbbcfdde5fba4954760c20", + "schema_version": 1, + "summary": { + "allowed_diff_count": 3, + "backend_count": 2, + "case_count": 10, + "elapsed_seconds": 0.520872, + "invariant_failure_count": 0, + "passed_case_count": 10, + "unexpected_diff_count": 0 + } +} diff --git a/tests/sessions/replay_cases/README.md b/tests/sessions/replay_cases/README.md new file mode 100644 index 000000000..a0b1665a6 --- /dev/null +++ b/tests/sessions/replay_cases/README.md @@ -0,0 +1,31 @@ +# Replay Consistency Design + +The replay harness loads ten JSONL traces and applies each operation to a paired +SessionService and MemoryService. InMemory and SQLite run by default, so CI gets +a real persistence round trip without external infrastructure. Set +`TRPC_REPLAY_BACKENDS=inmemory` for the smallest mode. A disposable SQL or Redis +backend can be added with `TRPC_REPLAY_SQL_URL` or `TRPC_REPLAY_REDIS_URL`; +explicit backend selection uses `TRPC_REPLAY_BACKENDS=inmemory,sql,redis`. + +Comparison removes only non-business variance. Physical event IDs map to logical +replay IDs, wall-clock timestamps become a typed marker, JSON objects are +compared structurally, and memory matches are sorted because equal-keyword +ranking is not part of the MemoryService contract. Summary text uses Unicode +NFKC, case folding, and whitespace collapse. Summary ownership, version, +supersedes relation, event counts, and active anchor count remain exact. The +report records any raw memory-order or recovery-mechanism difference under a +narrow `allowed_diffs` rule; content, count, state, event order, and summary +metadata are never globally ignored. + +Duplicate append and failed summary replacement cases use compensating replay +recovery, then reload the backend to detect duplicate events, dirty state, stale +anchors, or an incorrect cached summary. Per-backend expectations keep +InMemory-only runs meaningful, while pairwise comparison exposes backend drift. +Every unexpected difference includes the logical session ID, JSON field path, +event index or summary ID when applicable, and both backend values. Run the +harness directly to refresh the report: + +```bash +python -m tests.sessions.replay_harness \ + --output session_memory_summary_diff_report.json +``` diff --git a/tests/sessions/replay_cases/session_memory_summary.jsonl b/tests/sessions/replay_cases/session_memory_summary.jsonl new file mode 100644 index 000000000..3173ab4b3 --- /dev/null +++ b/tests/sessions/replay_cases/session_memory_summary.jsonl @@ -0,0 +1,10 @@ +{"case_id":"single_turn","description":"One user message followed by one assistant response.","session_id":"session-single","operations":[{"op":"append_event","event":{"id":"single-user-1","timestamp":1700000001.0,"author":"user","role":"user","text":"Hello, replay harness."}},{"op":"append_event","event":{"id":"single-agent-1","timestamp":1700000002.0,"author":"assistant","role":"model","text":"Hello. How can I help?"}}],"expect":{"active_event_count":2,"historical_event_count":0,"state":{},"summary_present":false}} +{"case_id":"multi_turn","description":"Three complete user and assistant turns preserve event order.","session_id":"session-multi","operations":[{"op":"append_event","event":{"id":"multi-user-1","timestamp":1700000011.0,"author":"user","role":"user","text":"My name is Ada."}},{"op":"append_event","event":{"id":"multi-agent-1","timestamp":1700000012.0,"author":"assistant","role":"model","text":"Nice to meet you, Ada."}},{"op":"append_event","event":{"id":"multi-user-2","timestamp":1700000013.0,"author":"user","role":"user","text":"What is my name?"}},{"op":"append_event","event":{"id":"multi-agent-2","timestamp":1700000014.0,"author":"assistant","role":"model","text":"Your name is Ada."}},{"op":"append_event","event":{"id":"multi-user-3","timestamp":1700000015.0,"author":"user","role":"user","text":"Remember that."}},{"op":"append_event","event":{"id":"multi-agent-3","timestamp":1700000016.0,"author":"assistant","role":"model","text":"I will remember it."}}],"expect":{"active_event_count":6,"historical_event_count":0,"state":{},"summary_present":false}} +{"case_id":"tool_call","description":"A function call and matching function response round trip through storage.","session_id":"session-tool","operations":[{"op":"append_event","event":{"id":"tool-user-1","timestamp":1700000021.0,"author":"user","role":"user","text":"What is the weather in Shenzhen?"}},{"op":"append_event","event":{"id":"tool-call-1","timestamp":1700000022.0,"author":"assistant","role":"model","function_call":{"id":"call-weather-1","name":"get_weather","args":{"city":"Shenzhen","unit":"celsius"}}}},{"op":"append_event","event":{"id":"tool-response-1","timestamp":1700000023.0,"author":"tool","role":"function","function_response":{"id":"call-weather-1","name":"get_weather","response":{"temperature":31,"condition":"sunny"}}}},{"op":"append_event","event":{"id":"tool-agent-1","timestamp":1700000024.0,"author":"assistant","role":"model","text":"It is 31 C and sunny in Shenzhen."}}],"expect":{"active_event_count":4,"historical_event_count":0,"state":{},"summary_present":false}} +{"case_id":"state_overwrite","description":"Session, user, and app state updates use last-write-wins semantics.","session_id":"session-state","initial_state":{"theme":"light","user:locale":"en","app:policy":"v1"},"operations":[{"op":"state_update","event":{"id":"state-update-1","timestamp":1700000031.0,"author":"system","state_delta":{"theme":"dark","counter":1}}},{"op":"state_update","event":{"id":"state-update-2","timestamp":1700000032.0,"author":"system","state_delta":{"theme":"solarized","counter":2,"user:locale":"fr","app:policy":"v2","temp:request":"not-persisted"}}}],"expect":{"active_event_count":2,"historical_event_count":0,"state":{"theme":"solarized","counter":2,"user:locale":"fr","app:policy":"v2"},"summary_present":false}} +{"case_id":"memory_write_read","description":"Preference and fact memories are stored and retrieved by keyword.","session_id":"session-memory","operations":[{"op":"append_event","event":{"id":"memory-user-1","timestamp":1700000041.0,"author":"user","role":"user","text":"I prefer green tea without sugar."}},{"op":"append_event","event":{"id":"memory-agent-1","timestamp":1700000042.0,"author":"assistant","role":"model","text":"I will remember your tea preference."}},{"op":"append_event","event":{"id":"memory-user-2","timestamp":1700000043.0,"author":"user","role":"user","text":"My timezone is Asia Shanghai."}},{"op":"append_event","event":{"id":"memory-agent-2","timestamp":1700000044.0,"author":"assistant","role":"model","text":"Your timezone preference is saved."}},{"op":"store_memory"},{"op":"search_memory","label":"tea_preference","query":"tea"},{"op":"search_memory","label":"timezone_fact","query":"timezone"}],"expect":{"active_event_count":4,"historical_event_count":0,"state":{},"memory_counts":{"tea_preference":2,"timezone_fact":2},"summary_present":false}} +{"case_id":"summary_create_update","description":"A second summary supersedes the first revision without losing ownership metadata.","session_id":"session-summary-update","operations":[{"op":"append_event","event":{"id":"summary-u1","timestamp":1700000051.0,"author":"user","role":"user","text":"Plan a release checklist."}},{"op":"append_event","event":{"id":"summary-a1","timestamp":1700000052.0,"author":"assistant","role":"model","text":"Start with tests and changelog."}},{"op":"append_event","event":{"id":"summary-u2","timestamp":1700000053.0,"author":"user","role":"user","text":"Add a security review."}},{"op":"append_event","event":{"id":"summary-a2","timestamp":1700000054.0,"author":"assistant","role":"model","text":"Security review added."}},{"op":"append_event","event":{"id":"summary-u3","timestamp":1700000055.0,"author":"user","role":"user","text":"Keep the latest decisions."}},{"op":"append_event","event":{"id":"summary-a3","timestamp":1700000056.0,"author":"assistant","role":"model","text":"The decisions are retained."}},{"op":"summarize","summary_id":"summary-update-v1","version":1,"text":"The release checklist includes tests, changelog, and a security review."},{"op":"append_event","event":{"id":"summary-u4","timestamp":1700000057.0,"author":"user","role":"user","text":"The release owner is Lin."}},{"op":"append_event","event":{"id":"summary-a4","timestamp":1700000058.0,"author":"assistant","role":"model","text":"Lin is the release owner."}},{"op":"summarize","summary_id":"summary-update-v2","version":2,"supersedes":"summary-update-v1","text":"The release needs tests, changelog, and security review. Lin owns the release."}],"expect":{"active_event_count":3,"historical_event_count":7,"state":{},"summary_present":true,"summary_id":"summary-update-v2","summary_version":2,"summary_supersedes":"summary-update-v1","summary_text":"the release needs tests, changelog, and security review. lin owns the release.","summary_session_id":"session-summary-update","summary_anchor_count":1}} +{"case_id":"summary_event_truncation","description":"A summary anchor, retained events, and later events reconstruct compressed context.","session_id":"session-summary-truncate","operations":[{"op":"append_event","event":{"id":"truncate-u1","timestamp":1700000061.0,"author":"user","role":"user","text":"The project codename is Orion."}},{"op":"append_event","event":{"id":"truncate-a1","timestamp":1700000062.0,"author":"assistant","role":"model","text":"Codename Orion noted."}},{"op":"append_event","event":{"id":"truncate-u2","timestamp":1700000063.0,"author":"user","role":"user","text":"The target date is Friday."}},{"op":"append_event","event":{"id":"truncate-a2","timestamp":1700000064.0,"author":"assistant","role":"model","text":"Friday is the target date."}},{"op":"append_event","event":{"id":"truncate-u3","timestamp":1700000065.0,"author":"user","role":"user","text":"Keep two recent events."}},{"op":"append_event","event":{"id":"truncate-a3","timestamp":1700000066.0,"author":"assistant","role":"model","text":"Two recent events will remain."}},{"op":"summarize","summary_id":"summary-truncate-v1","version":1,"text":"Project Orion targets Friday."},{"op":"append_event","event":{"id":"truncate-u4","timestamp":1700000067.0,"author":"user","role":"user","text":"Add a deployment rehearsal."}},{"op":"append_event","event":{"id":"truncate-a4","timestamp":1700000068.0,"author":"assistant","role":"model","text":"Deployment rehearsal added."}}],"expect":{"active_event_count":5,"historical_event_count":4,"state":{},"summary_present":true,"summary_id":"summary-truncate-v1","summary_version":1,"summary_text":"project orion targets friday.","summary_session_id":"session-summary-truncate","summary_anchor_count":1}} +{"case_id":"summary_semantic_normalization","description":"Summary comparison normalizes Unicode case and whitespace but preserves metadata.","session_id":"session-summary-semantic","operations":[{"op":"append_event","event":{"id":"semantic-u1","timestamp":1700000071.0,"author":"user","role":"user","text":"I chose tea."}},{"op":"append_event","event":{"id":"semantic-a1","timestamp":1700000072.0,"author":"assistant","role":"model","text":"Tea choice recorded."}},{"op":"append_event","event":{"id":"semantic-u2","timestamp":1700000073.0,"author":"user","role":"user","text":"Brew it next."}},{"op":"append_event","event":{"id":"semantic-a2","timestamp":1700000074.0,"author":"assistant","role":"model","text":"Brewing is next."}},{"op":"summarize","summary_id":"summary-semantic-v1","version":1,"text":" User chose TEA.\nNext: brew. "}],"expect":{"active_event_count":3,"historical_event_count":2,"state":{},"summary_present":true,"summary_id":"summary-semantic-v1","summary_version":1,"summary_text":"user chose tea. next: brew.","summary_session_id":"session-summary-semantic","summary_anchor_count":1}} +{"case_id":"duplicate_write_recovery","description":"A retried event write is detected and recovered to one logical event.","session_id":"session-duplicate","operations":[{"op":"append_event","event":{"id":"duplicate-u1","timestamp":1700000081.0,"author":"user","role":"user","text":"Run once."}},{"op":"append_event","event":{"id":"duplicate-a1","timestamp":1700000082.0,"author":"assistant","role":"model","text":"Completed once."}},{"op":"duplicate_append","event":{"id":"duplicate-a1","timestamp":1700000082.0,"author":"assistant","role":"model","text":"Completed once."}}],"expect":{"active_event_count":2,"historical_event_count":0,"state":{},"summary_present":false,"unique_event_ids":true,"recovery_kinds":["duplicate_append"]}} +{"case_id":"failed_summary_recovery","description":"A failure after summary replacement restores events, state, and the prior summary revision.","session_id":"session-failed-summary","operations":[{"op":"append_event","event":{"id":"failure-u1","timestamp":1700000091.0,"author":"user","role":"user","text":"The migration starts Monday."}},{"op":"append_event","event":{"id":"failure-a1","timestamp":1700000092.0,"author":"assistant","role":"model","text":"Monday start recorded."}},{"op":"append_event","event":{"id":"failure-u2","timestamp":1700000093.0,"author":"user","role":"user","text":"Backups are mandatory."}},{"op":"append_event","event":{"id":"failure-a2","timestamp":1700000094.0,"author":"assistant","role":"model","text":"Backups are mandatory."}},{"op":"append_event","event":{"id":"failure-u3","timestamp":1700000095.0,"author":"user","role":"user","text":"Keep the newest turn."}},{"op":"append_event","event":{"id":"failure-a3","timestamp":1700000096.0,"author":"assistant","role":"model","text":"Newest turn retained."}},{"op":"summarize","summary_id":"summary-failure-v1","version":1,"text":"Migration starts Monday and requires backups."},{"op":"append_event","event":{"id":"failure-u4","timestamp":1700000097.0,"author":"user","role":"user","text":"Notify operations."}},{"op":"append_event","event":{"id":"failure-a4","timestamp":1700000098.0,"author":"assistant","role":"model","text":"Operations will be notified."}},{"op":"fail_summary","summary_id":"summary-failure-v2","version":2,"supersedes":"summary-failure-v1","text":"Migration starts Monday, requires backups, and operations must be notified."}],"expect":{"active_event_count":5,"historical_event_count":4,"state":{},"summary_present":true,"summary_id":"summary-failure-v1","summary_version":1,"summary_text":"migration starts monday and requires backups.","summary_session_id":"session-failed-summary","summary_anchor_count":1,"recovery_kinds":["summary_update"]}} diff --git a/tests/sessions/replay_harness.py b/tests/sessions/replay_harness.py new file mode 100644 index 000000000..7e6f33d9f --- /dev/null +++ b/tests/sessions/replay_harness.py @@ -0,0 +1,1005 @@ +# 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, and Summary multi-backend replay harness.""" + +from __future__ import annotations + +import argparse +import asyncio +import copy +import hashlib +import json +import math +import os +import re +import time +import unicodedata +import uuid +from collections import deque +from datetime import datetime +from datetime import timezone +from pathlib import Path +from typing import Any +from typing import Iterable +from typing import Mapping +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 LlmResponse +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.sessions import RedisSessionService +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.sessions import SqlSessionService +from trpc_agent_sdk.sessions import SummarizerSessionManager +from trpc_agent_sdk.sessions._session_summarizer import SessionSummarizer +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") / "session_memory_summary.jsonl" +SUMMARY_PREFIX = "Previous conversation summary:" + +NORMALIZATION_RULES = [ + { + "path": "$.events[*].timestamp", + "strategy": "replace_with_", + "reason": "Wall-clock timestamps are non-business metadata.", + }, + { + "path": "$.events[*].id", + "strategy": "logical_replay_id_or_stable_index", + "reason": "Backends and summary generation may allocate different physical IDs.", + }, + { + "path": "$.summary.*.text", + "strategy": "unicode_nfkc_casefold_and_whitespace_collapse", + "reason": "Summary content is compared semantically for formatting-only differences.", + }, + { + "path": "$.memory.*", + "strategy": "sort_by_normalized_content_author", + "reason": "MemoryService does not define result ordering for equal keyword matches.", + }, + { + "path": "$.*", + "strategy": "structural_json_comparison", + "reason": "Serialized object key order is not business data.", + }, +] + +ALLOWED_DIFF_RULES = [ + { + "path": "$.memory.*", + "scope": "order_only", + "reason": "Keyword-memory ranking order is backend-specific; entry content and count must still match.", + }, + { + "path": "$.recovery_raw[*].mechanism", + "scope": "mechanism_only", + "reason": "A backend may reject a duplicate transactionally or require compensating cleanup.", + }, +] + + +class ReplaySummaryModel: + """Deterministic model used to exercise the real summarizer pipeline.""" + + name = "replay-summary-model" + + def __init__(self) -> None: + self._responses: deque[str] = deque() + + def enqueue(self, text: str) -> None: + """Queue one deterministic summary response.""" + self._responses.append(text) + + async def generate_async(self, _request, stream: bool = False, ctx=None): + """Yield the queued response through the model interface.""" + del stream, ctx + if not self._responses: + raise RuntimeError("No replay summary response was queued") + yield LlmResponse(content=Content(role="model", parts=[Part.from_text(text=self._responses.popleft())])) + + +class ReplaySessionSummarizer(SessionSummarizer): + """Annotate generated summary anchors with replay ownership metadata.""" + + def __init__(self, model: ReplaySummaryModel) -> None: + super().__init__( + model=model, + check_summarizer_functions=[lambda _session: True], + keep_recent_count=2, + ) + self._revision: dict[str, Any] = {} + + def set_revision(self, summary_id: str, version: int, supersedes: Optional[str]) -> None: + """Set metadata for the next generated summary anchor.""" + self._revision = { + "summary_id": summary_id, + "version": version, + "supersedes": supersedes, + } + + async def create_session_summary( + self, + session: Session, + ctx=None, + store_historical_events: bool = False, + ) -> Optional[str]: + """Generate a summary and attach metadata before backend persistence.""" + summary_text = await super().create_session_summary( + session, + ctx=ctx, + store_historical_events=store_historical_events, + ) + if not summary_text: + return summary_text + + summary_event = next((event for event in session.events if event.is_summary_event()), None) + if summary_event is None: + raise RuntimeError("Summarizer returned text without a summary anchor event") + + retained_events = [event for event in session.events if event is not summary_event] + if retained_events: + summary_event.timestamp = min(event.timestamp for event in retained_events) - 0.001 + summary_event.version = self._revision["version"] + metadata = dict(summary_event.custom_metadata or {}) + metadata["replay_summary"] = { + **self._revision, + "session_id": session.id, + } + summary_event.custom_metadata = metadata + return summary_text + + +class ReplayBackend: + """A paired SessionService and MemoryService backend.""" + + def __init__(self, name: str, session_service, memory_service, model: ReplaySummaryModel, + summarizer: ReplaySessionSummarizer, manager: SummarizerSessionManager) -> None: + self.name = name + self.session_service = session_service + self.memory_service = memory_service + self.model = model + self.summarizer = summarizer + self.manager = manager + + async def close(self) -> None: + """Release both backend services.""" + await self.memory_service.close() + await self.session_service.close() + + +def load_replay_cases(path: Path = DEFAULT_CASES_PATH) -> list[dict[str, Any]]: + """Load and minimally validate JSONL replay cases.""" + cases = [] + with path.open("r", encoding="utf-8") as case_file: + for line_number, line in enumerate(case_file, start=1): + if not line.strip(): + continue + try: + case = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid replay JSON on line {line_number}: {exc}") from exc + missing = {"case_id", "session_id", "operations", "expect"} - set(case) + if missing: + raise ValueError(f"Replay case on line {line_number} is missing {sorted(missing)}") + cases.append(case) + + case_ids = [case["case_id"] for case in cases] + if len(case_ids) != len(set(case_ids)): + raise ValueError("Replay case IDs must be unique") + return cases + + +def normalize_summary_text(text: Optional[str]) -> Optional[str]: + """Normalize summary formatting while preserving words and punctuation.""" + if text is None: + return None + normalized = unicodedata.normalize("NFKC", text) + return " ".join(normalized.split()).casefold() + + +def _memory_config() -> MemoryServiceConfig: + config = MemoryServiceConfig(enabled=True) + config.clean_ttl_config() + return config + + +def _session_config(): + from trpc_agent_sdk.sessions import SessionServiceConfig + + config = SessionServiceConfig(store_historical_events=True) + config.clean_ttl_config() + return config + + +async def _build_backend(name: str, work_dir: Path, environ: Mapping[str, str]) -> ReplayBackend: + model = ReplaySummaryModel() + summarizer = ReplaySessionSummarizer(model) + manager = SummarizerSessionManager(model=model, summarizer=summarizer) + + if name == "inmemory": + session_service = InMemorySessionService( + summarizer_manager=manager, + session_config=_session_config(), + ) + memory_service = InMemoryMemoryService(memory_service_config=_memory_config()) + elif name == "sqlite": + sqlite_path = work_dir / "replay.sqlite3" + db_url = f"sqlite:///{sqlite_path}" + session_service = SqlSessionService( + db_url=db_url, + summarizer_manager=manager, + session_config=_session_config(), + ) + memory_service = SqlMemoryService( + db_url=db_url, + memory_service_config=_memory_config(), + ) + await session_service._sql_storage.create_sql_engine() + await memory_service._sql_storage.create_sql_engine() + elif name == "sql": + db_url = environ.get("TRPC_REPLAY_SQL_URL") + if not db_url: + raise ValueError("TRPC_REPLAY_SQL_URL is required when the sql backend is selected") + session_service = SqlSessionService( + db_url=db_url, + summarizer_manager=manager, + session_config=_session_config(), + ) + memory_service = SqlMemoryService( + db_url=db_url, + memory_service_config=_memory_config(), + ) + await session_service._sql_storage.create_sql_engine() + await memory_service._sql_storage.create_sql_engine() + elif name == "redis": + db_url = environ.get("TRPC_REPLAY_REDIS_URL") + if not db_url: + raise ValueError("TRPC_REPLAY_REDIS_URL is required when the redis backend is selected") + session_service = RedisSessionService( + db_url=db_url, + summarizer_manager=manager, + session_config=_session_config(), + ) + memory_service = RedisMemoryService( + db_url=db_url, + memory_service_config=_memory_config(), + ) + else: + raise ValueError(f"Unsupported replay backend: {name}") + + return ReplayBackend(name, session_service, memory_service, model, summarizer, manager) + + +def resolve_backend_names(environ: Mapping[str, str] = os.environ) -> list[str]: + """Resolve lightweight and opt-in integration backends from environment.""" + configured = environ.get("TRPC_REPLAY_BACKENDS") + if configured: + names = [name.strip().lower() for name in configured.split(",") if name.strip()] + else: + names = ["inmemory", "sqlite"] + if environ.get("TRPC_REPLAY_SQL_URL"): + names.append("sql") + if environ.get("TRPC_REPLAY_REDIS_URL"): + names.append("redis") + + if not names: + raise ValueError("At least one replay backend must be selected") + unknown = set(names) - {"inmemory", "sqlite", "sql", "redis"} + if unknown: + raise ValueError(f"Unsupported replay backends: {sorted(unknown)}") + return list(dict.fromkeys(names)) + + +def _build_event(data: Mapping[str, Any], timestamp_base: float) -> Event: + parts = [] + if "text" in data: + parts.append(Part.from_text(text=data["text"])) + if "function_call" in data: + function_call = data["function_call"] + parts.append( + Part(function_call=FunctionCall( + id=function_call.get("id"), + name=function_call["name"], + args=function_call.get("args", {}), + ))) + if "function_response" in data: + function_response = data["function_response"] + parts.append( + Part(function_response=FunctionResponse( + id=function_response.get("id"), + name=function_response["name"], + response=function_response.get("response", {}), + ))) + + content = Content(role=data.get("role"), parts=parts) if parts else None + return Event( + id=data["id"], + invocation_id=data.get("invocation_id", f"invocation-{data['id']}"), + author=data.get("author", "system"), + content=content, + actions=EventActions(state_delta=data.get("state_delta", {})), + timestamp=timestamp_base + (float(data["timestamp"]) - 1_700_000_000.0) / 1_000.0, + custom_metadata={"replay_event_id": data["id"]}, + ) + + +def _canonical_json(value: Any) -> Any: + if isinstance(value, dict): + return {key: _canonical_json(value[key]) for key in sorted(value)} + if isinstance(value, list): + return [_canonical_json(item) for item in value] + if isinstance(value, set): + return sorted(_canonical_json(item) for item in value) + return value + + +def _canonical_event(event: Event, index: int) -> dict[str, Any]: + data = event.model_dump(mode="json", exclude_none=True) + metadata = dict(data.get("custom_metadata") or {}) + replay_event_id = metadata.pop("replay_event_id", None) + summary_metadata = metadata.get("replay_summary") + + data["id"] = (summary_metadata or {}).get("summary_id") or replay_event_id or f"event-{index}" + data["timestamp"] = "" + if metadata: + data["custom_metadata"] = metadata + else: + data.pop("custom_metadata", None) + if event.is_summary_event() and data.get("content", {}).get("parts"): + text = data["content"]["parts"][0].get("text") + if text and text.startswith(SUMMARY_PREFIX): + data["content"]["parts"][0]["text"] = normalize_summary_text(text.removeprefix(SUMMARY_PREFIX)) + if data.get("long_running_tool_ids"): + data["long_running_tool_ids"] = sorted(data["long_running_tool_ids"]) + else: + data.pop("long_running_tool_ids", None) + return _canonical_json(data) + + +def _canonical_memory_entry(entry) -> dict[str, Any]: + return _canonical_json({ + "author": entry.author, + "content": entry.content.model_dump(mode="json", exclude_none=True), + "timestamp": "" if entry.timestamp else None, + }) + + +def _memory_sort_key(entry: Mapping[str, Any]) -> str: + return json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _summary_anchor_text(anchor: Optional[Event]) -> Optional[str]: + if not anchor or not anchor.content or not anchor.content.parts: + return None + text = anchor.content.parts[0].text + if text and text.startswith(SUMMARY_PREFIX): + text = text.removeprefix(SUMMARY_PREFIX) + return normalize_summary_text(text) + + +async def _capture_live_summary(backend: ReplayBackend, session: Session) -> Optional[dict[str, Any]]: + summary = await backend.manager.get_session_summary(session) + service_text = await backend.session_service.get_session_summary(session) + anchors = [event for event in session.events if event.is_summary_event()] + if summary is None and not anchors: + return None + + anchor = anchors[0] if anchors else None + metadata = ((anchor.custom_metadata or {}).get("replay_summary", {}) if anchor else {}) + timestamp_is_valid = bool(summary and math.isfinite(summary.summary_timestamp) and summary.summary_timestamp > 0) + return { + "summary_id": metadata.get("summary_id"), + "session_id": metadata.get("session_id") or (summary.session_id if summary else None), + "version": metadata.get("version") if metadata else (anchor.version if anchor else None), + "supersedes": metadata.get("supersedes"), + "text": normalize_summary_text(service_text or (summary.summary_text if summary else None)), + "anchor_text": _summary_anchor_text(anchor), + "anchor_count": len(anchors), + "original_event_count": summary.original_event_count if summary else None, + "compressed_event_count": summary.compressed_event_count if summary else None, + "updated_at": "" if timestamp_is_valid else "", + } + + +async def _perform_summary(backend: ReplayBackend, session: Session, operation: Mapping[str, Any], revision_number: int, + previous_summary_timestamp: Optional[float]) -> tuple[Session, dict[str, Any], float]: + backend.model.enqueue(operation["text"]) + backend.summarizer.set_revision( + operation["summary_id"], + operation["version"], + operation.get("supersedes"), + ) + await backend.manager.create_session_summary(session, force=True) + reloaded = await backend.session_service.get_session( + app_name=session.app_name, + user_id=session.user_id, + session_id=session.id, + ) + if reloaded is None: + raise RuntimeError(f"Session {session.id} disappeared after summary update") + revision = await _capture_live_summary(backend, reloaded) + if revision is None: + raise RuntimeError(f"Summary {operation['summary_id']} was not readable after persistence") + live_summary = await backend.manager.get_session_summary(reloaded) + if live_summary is None or not math.isfinite(live_summary.summary_timestamp) or live_summary.summary_timestamp <= 0: + raise RuntimeError(f"Summary {operation['summary_id']} has an invalid update timestamp") + if previous_summary_timestamp is not None and live_summary.summary_timestamp <= previous_summary_timestamp: + raise RuntimeError(f"Summary {operation['summary_id']} did not advance its update timestamp") + revision["updated_at_order"] = revision_number + return reloaded, revision, live_summary.summary_timestamp + + +def _deduplicate_events(events: Iterable[Event]) -> list[Event]: + seen = set() + deduplicated = [] + for event in events: + if event.id in seen: + continue + seen.add(event.id) + deduplicated.append(event) + return deduplicated + + +def _summary_cache_entry(backend: ReplayBackend, session: Session): + return backend.manager._summarizer_cache.get(session.app_name, {}).get(session.user_id, {}).get(session.id) + + +def _restore_summary_cache(backend: ReplayBackend, session: Session, summary) -> None: + app_cache = backend.manager._summarizer_cache.setdefault(session.app_name, {}) + user_cache = app_cache.setdefault(session.user_id, {}) + if summary is None: + user_cache.pop(session.id, None) + else: + user_cache[session.id] = summary + + +async def _run_case(backend: ReplayBackend, case: Mapping[str, Any], run_token: str) -> dict[str, Any]: + app_name = f"replay-{run_token}-{case['case_id']}" + user_id = "replay-user" + session = await backend.session_service.create_session( + app_name=app_name, + user_id=user_id, + session_id=case["session_id"], + state=case.get("initial_state"), + ) + memory = {} + raw_memory_order = {} + revisions = [] + operation_audit = [] + recovery_raw = [] + timestamp_base = time.time() + summary_timestamp = None + + for operation in case["operations"]: + operation_name = operation["op"] + if operation_name in {"append_event", "state_update"}: + await backend.session_service.append_event(session, _build_event(operation["event"], timestamp_base)) + elif operation_name == "store_memory": + await backend.memory_service.store_session(session) + elif operation_name == "search_memory": + response = await backend.memory_service.search_memory( + session.save_key, + operation["query"], + limit=operation.get("limit", 10), + ) + raw_entries = [_canonical_memory_entry(entry) for entry in response.memories] + raw_memory_order[operation["label"]] = raw_entries + memory[operation["label"]] = sorted(raw_entries, key=_memory_sort_key) + elif operation_name == "summarize": + session, revision, summary_timestamp = await _perform_summary( + backend, + session, + operation, + len(revisions) + 1, + summary_timestamp, + ) + revisions.append(revision) + elif operation_name == "duplicate_append": + duplicate_event = _build_event(operation["event"], timestamp_base) + mechanism = "backend_append" + append_error = None + try: + await backend.session_service.append_event(session, duplicate_event) + except Exception as exc: # pylint: disable=broad-except + append_error = type(exc).__name__ + mechanism = "transactional_rejection" + + fresh = await backend.session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=case["session_id"], + ) + if fresh is None: + raise RuntimeError("Session disappeared during duplicate recovery") + duplicate_count = sum(event.id == duplicate_event.id for event in fresh.events) + if duplicate_count > 1: + mechanism = "compensating_deduplication" + fresh.events = _deduplicate_events(fresh.events) + fresh.historical_events = _deduplicate_events(fresh.historical_events) + await backend.session_service.update_session(fresh) + fresh = await backend.session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=case["session_id"], + ) + session = fresh + final_count = sum(event.id == duplicate_event.id for event in session.events) + operation_audit.append({ + "kind": "duplicate_append", + "recovered": final_count == 1, + "final_event_count": final_count, + }) + recovery_raw.append({ + "kind": "duplicate_append", + "mechanism": mechanism, + "append_error": append_error, + "observed_duplicate_count": duplicate_count, + }) + elif operation_name == "fail_summary": + session_checkpoint = session.model_copy(deep=True) + summary_checkpoint = copy.deepcopy(_summary_cache_entry(backend, session)) + try: + await _perform_summary( + backend, + session, + operation, + len(revisions) + 1, + summary_timestamp, + ) + raise RuntimeError("injected_failure_after_summary_persistence") + except RuntimeError as exc: + if str(exc) != "injected_failure_after_summary_persistence": + raise + await backend.session_service.update_session(session_checkpoint) + _restore_summary_cache(backend, session_checkpoint, summary_checkpoint) + session = await backend.session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=case["session_id"], + ) + if session is None: + raise RuntimeError("Session disappeared during summary recovery") + live_summary = await _capture_live_summary(backend, session) + restored_summary = await backend.manager.get_session_summary(session) + recovered = bool(live_summary and summary_checkpoint and restored_summary + and live_summary["summary_id"] == revisions[-1]["summary_id"] + and restored_summary.summary_timestamp == summary_checkpoint.summary_timestamp) + operation_audit.append({ + "kind": "summary_update", + "recovered": recovered, + "final_event_count": len(session.events), + }) + recovery_raw.append({ + "kind": "summary_update", + "mechanism": "compensating_update", + "append_error": "injected_failure_after_summary_persistence", + }) + else: + raise ValueError(f"Unsupported operation {operation_name!r} in case {case['case_id']}") + + session = await backend.session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=case["session_id"], + ) + if session is None: + raise RuntimeError(f"Session {case['session_id']} was not readable after replay") + + snapshot = { + "events": [_canonical_event(event, index) for index, event in enumerate(session.events)], + "historical_events": [_canonical_event(event, index) for index, event in enumerate(session.historical_events)], + "state": _canonical_json(session.state), + "memory": _canonical_json(memory), + "summary": { + "current": await _capture_live_summary(backend, session), + "revisions": revisions, + }, + "operation_audit": operation_audit, + } + invariant_failures = validate_expectations(case, snapshot) + return { + "backend": backend.name, + "case_id": case["case_id"], + "session_id": case["session_id"], + "operation_count": len(case["operations"]), + "snapshot": snapshot, + "raw_memory_order": raw_memory_order, + "recovery_raw": recovery_raw, + "invariant_failures": invariant_failures, + "error": None, + } + + +def _invariant_failure(case: Mapping[str, Any], path: str, expected: Any, actual: Any) -> dict[str, Any]: + return { + "case_id": case["case_id"], + "session_id": case["session_id"], + "path": path, + "expected": expected, + "actual": actual, + } + + +def validate_expectations(case: Mapping[str, Any], snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + """Validate per-backend invariants so InMemory-only mode remains useful.""" + expect = case["expect"] + failures = [] + + checks = [ + ("$.events.length", expect.get("active_event_count"), len(snapshot["events"])), + ("$.historical_events.length", expect.get("historical_event_count"), len(snapshot["historical_events"])), + ("$.state", expect.get("state"), snapshot["state"]), + ] + current_summary = snapshot["summary"]["current"] + summary_present = current_summary is not None + checks.append(("$.summary.current.present", expect.get("summary_present"), summary_present)) + + if summary_present: + summary_checks = { + "$.summary.current.summary_id": "summary_id", + "$.summary.current.version": "summary_version", + "$.summary.current.supersedes": "summary_supersedes", + "$.summary.current.text": "summary_text", + "$.summary.current.session_id": "summary_session_id", + "$.summary.current.anchor_count": "summary_anchor_count", + } + for path, expected_key in summary_checks.items(): + if expected_key in expect: + checks.append((path, expect[expected_key], current_summary[path.rsplit(".", maxsplit=1)[-1]])) + + for label, count in expect.get("memory_counts", {}).items(): + checks.append((f"$.memory.{label}.length", count, len(snapshot["memory"].get(label, [])))) + + if expect.get("unique_event_ids"): + event_ids = [event["id"] for event in snapshot["events"]] + checks.append(("$.events.unique_ids", len(event_ids), len(set(event_ids)))) + + expected_recovery_kinds = expect.get("recovery_kinds") + if expected_recovery_kinds is not None: + actual_kinds = [entry["kind"] for entry in snapshot["operation_audit"] if entry["recovered"]] + checks.append(("$.operation_audit.recovered_kinds", expected_recovery_kinds, actual_kinds)) + + for path, expected_value, actual_value in checks: + if expected_value is not None and expected_value != actual_value: + failures.append(_invariant_failure(case, path, expected_value, actual_value)) + return failures + + +async def run_replay_harness( + work_dir: Path, + cases_path: Path = DEFAULT_CASES_PATH, + backend_names: Optional[list[str]] = None, + environ: Mapping[str, str] = os.environ, +) -> dict[str, Any]: + """Replay every case against each selected backend.""" + started = time.perf_counter() + work_dir.mkdir(parents=True, exist_ok=True) + cases = load_replay_cases(cases_path) + names = backend_names or resolve_backend_names(environ) + run_token = uuid.uuid4().hex[:10] + results = [] + backends = [] + try: + for name in names: + backend_dir = work_dir / name + backend_dir.mkdir(parents=True, exist_ok=True) + backends.append(await _build_backend(name, backend_dir, environ)) + + for backend in backends: + for case in cases: + try: + results.append(await _run_case(backend, case, run_token)) + except Exception as exc: # pylint: disable=broad-except + results.append({ + "backend": + backend.name, + "case_id": + case["case_id"], + "session_id": + case["session_id"], + "operation_count": + len(case["operations"]), + "snapshot": { + "error": { + "type": type(exc).__name__, + "message": str(exc), + } + }, + "raw_memory_order": {}, + "recovery_raw": [], + "invariant_failures": + [_invariant_failure(case, "$.replay", "completed", f"{type(exc).__name__}: {exc}")], + "error": { + "type": type(exc).__name__, + "message": str(exc), + }, + }) + finally: + for backend in reversed(backends): + await backend.close() + + return { + "cases": cases, + "backend_names": names, + "results": results, + "elapsed_seconds": time.perf_counter() - started, + } + + +def _value_diffs(reference: Any, candidate: Any, path: str = "$") -> list[dict[str, Any]]: + if isinstance(reference, dict) and isinstance(candidate, dict): + differences = [] + for key in sorted(set(reference) | set(candidate)): + child_path = f"{path}.{key}" + if key not in reference: + differences.append({"path": child_path, "reference_value": None, "backend_value": candidate[key]}) + elif key not in candidate: + differences.append({"path": child_path, "reference_value": reference[key], "backend_value": None}) + else: + differences.extend(_value_diffs(reference[key], candidate[key], child_path)) + return differences + + if isinstance(reference, list) and isinstance(candidate, list): + differences = [] + for index in range(max(len(reference), len(candidate))): + child_path = f"{path}[{index}]" + if index >= len(reference): + differences.append({"path": child_path, "reference_value": None, "backend_value": candidate[index]}) + elif index >= len(candidate): + differences.append({"path": child_path, "reference_value": reference[index], "backend_value": None}) + else: + differences.extend(_value_diffs(reference[index], candidate[index], child_path)) + return differences + + if reference != candidate: + return [{"path": path, "reference_value": reference, "backend_value": candidate}] + return [] + + +def _domain_for_path(path: str) -> str: + if path.startswith("$.events") or path.startswith("$.historical_events"): + return "events" + if path.startswith("$.state"): + return "state" + if path.startswith("$.memory"): + return "memory" + if path.startswith("$.summary"): + return "summary" + if path.startswith("$.operation_audit"): + return "recovery" + return "replay" + + +def _event_index_for_path(path: str) -> Optional[int]: + match = re.search(r"\.(?:events|historical_events)\[(\d+)\]", path) + return int(match.group(1)) if match else None + + +def _summary_id_for_path(path: str, reference: Mapping[str, Any], candidate: Mapping[str, Any]) -> Optional[str]: + revision_match = re.search(r"\.summary\.revisions\[(\d+)\]", path) + if revision_match: + index = int(revision_match.group(1)) + for snapshot in (candidate, reference): + revisions = snapshot.get("summary", {}).get("revisions", []) + if index < len(revisions): + return revisions[index].get("summary_id") + for snapshot in (candidate, reference): + current = snapshot.get("summary", {}).get("current") + if current: + return current.get("summary_id") + return None + + +def _locate_difference(difference: dict[str, Any], result: Mapping[str, Any], reference_backend: str, + reference_snapshot: Mapping[str, Any]) -> dict[str, Any]: + path = difference["path"] + return { + "case_id": result["case_id"], + "session_id": result["session_id"], + "reference_backend": reference_backend, + "backend": result["backend"], + "domain": _domain_for_path(path), + "path": path, + "event_index": _event_index_for_path(path), + "summary_id": _summary_id_for_path(path, reference_snapshot, result["snapshot"]), + "reference_value": difference["reference_value"], + "backend_value": difference["backend_value"], + "allowed": False, + "explanation": "Normalized business values differ.", + } + + +def _allowed_raw_differences(reference: Mapping[str, Any], result: Mapping[str, Any]) -> list[dict[str, Any]]: + allowed = [] + reference_memory = reference.get("raw_memory_order", {}) + backend_memory = result.get("raw_memory_order", {}) + for label in sorted(set(reference_memory) | set(backend_memory)): + reference_entries = reference_memory.get(label, []) + backend_entries = backend_memory.get(label, []) + if reference_entries != backend_entries and sorted(reference_entries, key=_memory_sort_key) == sorted( + backend_entries, key=_memory_sort_key): + allowed.append({ + "case_id": result["case_id"], + "session_id": result["session_id"], + "reference_backend": reference["backend"], + "backend": result["backend"], + "domain": "memory", + "path": f"$.memory.{label}", + "event_index": None, + "summary_id": None, + "reference_value": reference_entries, + "backend_value": backend_entries, + "allowed": True, + "explanation": ALLOWED_DIFF_RULES[0]["reason"], + }) + + if (reference.get("recovery_raw") != result.get("recovery_raw") and reference.get( + "snapshot", {}).get("operation_audit") == result.get("snapshot", {}).get("operation_audit")): + allowed.append({ + "case_id": result["case_id"], + "session_id": result["session_id"], + "reference_backend": reference["backend"], + "backend": result["backend"], + "domain": "recovery", + "path": "$.recovery_raw", + "event_index": None, + "summary_id": None, + "reference_value": reference.get("recovery_raw"), + "backend_value": result.get("recovery_raw"), + "allowed": True, + "explanation": ALLOWED_DIFF_RULES[1]["reason"], + }) + return allowed + + +def build_diff_report(run: Mapping[str, Any]) -> dict[str, Any]: + """Build a structured, field-locatable diff report from replay results.""" + result_index = {(result["case_id"], result["backend"]): result for result in run["results"]} + reference_backend = run["backend_names"][0] + report_cases = [] + all_differences = [] + all_allowed = [] + all_invariant_failures = [] + + for case in run["cases"]: + case_id = case["case_id"] + reference = result_index[(case_id, reference_backend)] + case_differences = [] + case_allowed = [] + backend_results = {} + + for backend_name in run["backend_names"]: + result = result_index[(case_id, backend_name)] + backend_results[backend_name] = { + "operation_count": result["operation_count"], + "snapshot": result["snapshot"], + "invariant_failures": result["invariant_failures"], + "error": result["error"], + } + all_invariant_failures.extend({ + **failure, + "backend": backend_name, + } for failure in result["invariant_failures"]) + if backend_name == reference_backend: + continue + differences = _value_diffs(reference["snapshot"], result["snapshot"]) + located = [ + _locate_difference(difference, result, reference_backend, reference["snapshot"]) + for difference in differences + ] + case_differences.extend(located) + case_allowed.extend(_allowed_raw_differences(reference, result)) + + all_differences.extend(case_differences) + all_allowed.extend(case_allowed) + report_cases.append({ + "case_id": + case_id, + "description": + case["description"], + "session_id": + case["session_id"], + "status": + "passed" if not case_differences and not any(data["invariant_failures"] + for data in backend_results.values()) else "failed", + "backend_results": + backend_results, + "allowed_diffs": + case_allowed, + "differences": + case_differences, + }) + + if len(run["backend_names"]) == 1: + mode = "inmemory-only" + elif any(name in {"sql", "redis"} for name in run["backend_names"]): + mode = "integration" + else: + mode = "lightweight-persistent" + + report = { + "schema_version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "mode": mode, + "reference_backend": reference_backend, + "backends": run["backend_names"], + "normalization_rules": NORMALIZATION_RULES, + "allowed_diff_rules": ALLOWED_DIFF_RULES, + "cases": report_cases, + "summary": { + "case_count": len(run["cases"]), + "backend_count": len(run["backend_names"]), + "passed_case_count": sum(case["status"] == "passed" for case in report_cases), + "unexpected_diff_count": len(all_differences), + "allowed_diff_count": len(all_allowed), + "invariant_failure_count": len(all_invariant_failures), + "elapsed_seconds": round(run["elapsed_seconds"], 6), + }, + } + report["report_sha256"] = hashlib.sha256( + json.dumps(report["cases"], ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + return report + + +def write_diff_report(report: Mapping[str, Any], path: Path) -> None: + """Write a stable, UTF-8 JSON report.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _print_report_summary(report: Mapping[str, Any], output_path: Path) -> None: + print("Session / Memory / Summary Replay Consistency") + print(f"Mode: {report['mode']}") + print(f"Backends: {', '.join(report['backends'])}") + print("") + print(f"{'CASE':32} {'STATUS':8} {'DIFFS':>5} {'ALLOWED':>7}") + print("-" * 58) + for case in report["cases"]: + print(f"{case['case_id'][:32]:32} {case['status'].upper():8} " + f"{len(case['differences']):5d} {len(case['allowed_diffs']):7d}") + print("-" * 58) + summary = report["summary"] + print(f"Passed: {summary['passed_case_count']}/{summary['case_count']} cases") + print(f"Unexpected diffs: {summary['unexpected_diff_count']}") + print(f"Invariant failures: {summary['invariant_failure_count']}") + print(f"Elapsed: {summary['elapsed_seconds']:.3f}s") + print(f"Report: {output_path}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cases", type=Path, default=DEFAULT_CASES_PATH) + parser.add_argument("--output", type=Path, default=Path("session_memory_summary_diff_report.json")) + parser.add_argument("--work-dir", type=Path, default=Path(".replay-work")) + parser.add_argument("--backends", help="Comma-separated override: inmemory,sqlite,sql,redis") + args = parser.parse_args() + backend_names = None + if args.backends: + backend_names = [name.strip() for name in args.backends.split(",") if name.strip()] + + run = asyncio.run(run_replay_harness( + work_dir=args.work_dir, + cases_path=args.cases, + backend_names=backend_names, + )) + report = build_diff_report(run) + write_diff_report(report, args.output) + _print_report_summary(report, args.output) + summary = report["summary"] + return 1 if summary["unexpected_diff_count"] or summary["invariant_failure_count"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/sessions/test_replay_consistency.py b/tests/sessions/test_replay_consistency.py new file mode 100644 index 000000000..8aa1d2922 --- /dev/null +++ b/tests/sessions/test_replay_consistency.py @@ -0,0 +1,177 @@ +# 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. +"""End-to-end consistency tests for Session, Memory, and Summary backends.""" + +from __future__ import annotations + +import asyncio +import copy +import json + +import pytest + +from tests.sessions.replay_harness import build_diff_report +from tests.sessions.replay_harness import run_replay_harness +from tests.sessions.replay_harness import write_diff_report + + +@pytest.fixture(scope="module") +def replay_run(tmp_path_factory): + """Run all public cases once and share the immutable result.""" + work_dir = tmp_path_factory.mktemp("session-replay") + return asyncio.run(run_replay_harness(work_dir=work_dir)) + + +def _case_result(run, case_id, backend): + return next(result for result in run["results"] if result["case_id"] == case_id and result["backend"] == backend) + + +def _injected_run(run, case_id, mutator): + """Build a two-backend run where one result contains a controlled defect.""" + injected = copy.deepcopy(run) + reference_backend = run["backend_names"][0] + reference_results = [copy.deepcopy(result) for result in run["results"] if result["backend"] == reference_backend] + faulty_results = copy.deepcopy(reference_results) + for result in faulty_results: + result["backend"] = "fault_injected" + if result["case_id"] == case_id: + mutator(result["snapshot"]) + injected["backend_names"] = [reference_backend, "fault_injected"] + injected["results"] = reference_results + faulty_results + return injected + + +def _change_single_turn(snapshot): + snapshot["events"][0]["author"] = "assistant" + + +def _change_multi_turn(snapshot): + snapshot["events"][1], snapshot["events"][2] = snapshot["events"][2], snapshot["events"][1] + + +def _change_tool_call(snapshot): + snapshot["events"][1]["content"]["parts"][0]["function_call"]["args"]["city"] = "Beijing" + + +def _change_state(snapshot): + snapshot["state"]["theme"] = "light" + + +def _change_memory(snapshot): + snapshot["memory"]["tea_preference"].pop() + + +def _lose_summary(snapshot): + snapshot["summary"]["current"] = None + + +def _change_summary_version(snapshot): + snapshot["summary"]["current"]["version"] = 99 + + +def _change_summary_session(snapshot): + snapshot["summary"]["current"]["session_id"] = "another-session" + + +def _duplicate_event(snapshot): + snapshot["events"].append(copy.deepcopy(snapshot["events"][-1])) + + +def _change_summary_overwrite(snapshot): + snapshot["summary"]["current"]["supersedes"] = "wrong-summary" + + +INJECTIONS = [ + ("single_turn", _change_single_turn), + ("multi_turn", _change_multi_turn), + ("tool_call", _change_tool_call), + ("state_overwrite", _change_state), + ("memory_write_read", _change_memory), + ("summary_create_update", _lose_summary), + ("summary_event_truncation", _change_summary_version), + ("summary_semantic_normalization", _change_summary_session), + ("duplicate_write_recovery", _duplicate_event), + ("failed_summary_recovery", _change_summary_overwrite), +] + + +def test_all_public_cases_match_without_false_positives(replay_run, tmp_path): + report = build_diff_report(replay_run) + report_path = tmp_path / "session_memory_summary_diff_report.json" + write_diff_report(report, report_path) + + assert len(replay_run["cases"]) == 10 + assert report["summary"]["passed_case_count"] == 10 + assert report["summary"]["unexpected_diff_count"] == 0 + assert report["summary"]["invariant_failure_count"] == 0 + assert report["summary"]["elapsed_seconds"] <= 30 + + persisted = json.loads(report_path.read_text(encoding="utf-8")) + assert persisted["report_sha256"] == report["report_sha256"] + assert all({"events", "state", "memory", "summary"} <= set(result["snapshot"]) for case in report["cases"] + for result in case["backend_results"].values()) + + +@pytest.mark.parametrize(("case_id", "mutator"), INJECTIONS, ids=[case_id for case_id, _ in INJECTIONS]) +def test_each_public_case_detects_an_injected_inconsistency(replay_run, case_id, mutator): + report = build_diff_report(_injected_run(replay_run, case_id, mutator)) + case_report = next(case for case in report["cases"] if case["case_id"] == case_id) + + assert case_report["status"] == "failed" + assert case_report["differences"] + assert all(difference["session_id"] == case_report["session_id"] for difference in case_report["differences"]) + assert all(difference["path"] for difference in case_report["differences"]) + assert all("reference_value" in difference and "backend_value" in difference + for difference in case_report["differences"]) + assert any(difference["event_index"] is not None or difference["summary_id"] is not None + or difference["domain"] in {"state", "memory"} for difference in case_report["differences"]) + + +def test_summary_loss_overwrite_and_wrong_session_are_all_detected(replay_run): + scenarios = [ + ("summary_create_update", _lose_summary, "$.summary.current"), + ("failed_summary_recovery", _change_summary_overwrite, "$.summary.current.supersedes"), + ("summary_semantic_normalization", _change_summary_session, "$.summary.current.session_id"), + ] + + for case_id, mutator, expected_path in scenarios: + report = build_diff_report(_injected_run(replay_run, case_id, mutator)) + case_report = next(case for case in report["cases"] if case["case_id"] == case_id) + assert any(difference["path"].startswith(expected_path) for difference in case_report["differences"]) + assert all(difference["domain"] == "summary" for difference in case_report["differences"]) + + +def test_summary_timestamps_are_valid_and_revisions_advance(replay_run): + for result in replay_run["results"]: + summary = result["snapshot"]["summary"] + if summary["current"] is None: + continue + + assert summary["current"]["updated_at"] == "" + assert [revision["updated_at_order"] + for revision in summary["revisions"]] == list(range(1, + len(summary["revisions"]) + 1)) + assert all(revision["updated_at"] == "" for revision in summary["revisions"]) + + +def test_allowed_differences_are_explicit_and_narrow(replay_run): + report = build_diff_report(replay_run) + + assert report["allowed_diff_rules"] + assert all(rule["scope"] in {"order_only", "mechanism_only"} for rule in report["allowed_diff_rules"]) + assert all(difference["allowed"] for case in report["cases"] for difference in case["allowed_diffs"]) + assert report["summary"]["allowed_diff_count"] >= 1 + + +def test_inmemory_only_lightweight_mode(tmp_path): + run = asyncio.run(run_replay_harness(work_dir=tmp_path, backend_names=["inmemory"])) + report = build_diff_report(run) + + assert report["mode"] == "inmemory-only" + assert report["summary"]["backend_count"] == 1 + assert report["summary"]["passed_case_count"] == 10 + assert report["summary"]["invariant_failure_count"] == 0 + assert report["summary"]["elapsed_seconds"] <= 30 diff --git a/tests/storage/test_redis.py b/tests/storage/test_redis.py index 2fbcd923b..2b71bb2b7 100644 --- a/tests/storage/test_redis.py +++ b/tests/storage/test_redis.py @@ -495,6 +495,49 @@ async def test_execute_command_with_method(self, async_storage): assert result == "OK" mock_conn.set.assert_called_once_with('key', 'value') + @pytest.mark.asyncio + async def test_execute_hset_uses_mapping_and_preserves_value_types(self, async_storage): + """Test multi-field hash writes use redis-py's mapping API.""" + mock_conn = AsyncMock() + mock_conn.hset = AsyncMock(return_value=2) + command = RedisCommand( + method='hset', + args=('state', 'theme', 'dark', 'counter', 2), + expire=RedisExpire(ttl=Ttl(enable=False)), + ) + + result = await async_storage.execute_command(mock_conn, command) + + assert result == 2 + mock_conn.hset.assert_called_once_with( + 'state', + mapping={ + 'theme': '"dark"', + 'counter': '2', + }, + ) + + @pytest.mark.asyncio + async def test_execute_hgetall_normalizes_bytes_and_json_values(self, async_storage): + """Test real Redis hash responses become typed string-keyed state.""" + mock_conn = AsyncMock() + mock_conn.hgetall = AsyncMock(return_value={ + b'theme': b'"dark"', + b'counter': b'2', + b'enabled': b'true', + }) + + result = await async_storage.execute_command( + mock_conn, + RedisCommand(method='hgetall', args=('state', )), + ) + + assert result == { + 'theme': 'dark', + 'counter': 2, + 'enabled': True, + } + @pytest.mark.asyncio async def test_execute_command_without_method(self, async_storage): """Test executing command without existing method.""" diff --git a/tests/storage/test_redis_cluster_storage.py b/tests/storage/test_redis_cluster_storage.py index 2312cae15..6754a4fad 100644 --- a/tests/storage/test_redis_cluster_storage.py +++ b/tests/storage/test_redis_cluster_storage.py @@ -38,7 +38,11 @@ async def lrange(self, key: str, start: int, end: int): return [f'{key}-event'] async def hgetall(self, key: str): - return {b"field": b"value"} + return { + b"field": b"value", + b"counter": b"2", + b"enabled": b"true", + } async def close(self): self.closed = True @@ -72,7 +76,11 @@ async def test_hgetall_normalizes_raw_redis_bytes(self): result = await storage.execute_command(client, RedisCommand(method="hgetall", args=("state", ))) - assert result == {"field": "value"} + assert result == { + "field": "value", + "counter": 2, + "enabled": True, + } async def test_async_client_is_created_from_seed_url(self): storage = RedisClusterStorage(redis_url="redis://seed:6379/0", is_async=True, max_connections=20) diff --git a/trpc_agent_sdk/storage/_redis.py b/trpc_agent_sdk/storage/_redis.py index 6cfbb88c7..45abc61c3 100644 --- a/trpc_agent_sdk/storage/_redis.py +++ b/trpc_agent_sdk/storage/_redis.py @@ -134,22 +134,41 @@ def _serialize_value(self, value: Any) -> str: return str(value) return json.dumps(value, default=str) - def _deserialize_value(self, value: Optional[bytes]) -> Any: - """Deserialize value from Redis bytes.""" + def _deserialize_value(self, value: Any) -> Any: + """Deserialize a value returned as Redis bytes or decoded text.""" if value is None: return None - try: - value_str = value.decode('utf-8') - # Try to parse as JSON first + if isinstance(value, bytes): try: - return json.loads(value_str) - except json.JSONDecodeError: - # If not JSON, return as string - return value_str - except UnicodeDecodeError: + value_str = value.decode('utf-8') + except UnicodeDecodeError: + return value + elif isinstance(value, str): + value_str = value + else: return value + try: + return json.loads(value_str) + except json.JSONDecodeError: + return value_str + + @staticmethod + def _decode_hash_key(value: Any) -> Any: + """Decode a Redis hash key without changing its string semantics.""" + if isinstance(value, bytes): + try: + return value.decode('utf-8') + except UnicodeDecodeError: + return value + return value + + @staticmethod + def _serialize_hash_value(value: Any) -> str: + """Serialize hash values as JSON so state types survive round trips.""" + return json.dumps(value, ensure_ascii=False, default=str) + @override async def add(self, conn: RedisSession, data: RedisCommand) -> None: """Add data to Redis.""" @@ -266,12 +285,27 @@ async def execute_command(self, conn: RedisSession, command: RedisCommand) -> An lower_method = command.method.lower() upper_method = command.method.upper() method = getattr(conn, lower_method, None) + args = command.args + kwargs = dict(command.kwargs) + + # redis-py accepts one field/value pair positionally. Multiple pairs + # must use ``mapping``; JSON encoding also preserves state value types. + if lower_method == 'hset' and len(args) >= 3 and len(args[1:]) % 2 == 0 and 'mapping' not in kwargs: + mapping = { + self._decode_hash_key(args[index]): self._serialize_hash_value(args[index + 1]) + for index in range(1, len(args), 2) + } + args = (args[0], ) + kwargs['mapping'] = mapping + if method: - ret = method(*command.args, **command.kwargs) + ret = method(*args, **kwargs) else: - ret = conn.execute_command(upper_method, *command.args, **command.kwargs) + ret = conn.execute_command(upper_method, *args, **kwargs) if asyncio.iscoroutine(ret): ret = await ret + if lower_method == 'hgetall' and isinstance(ret, dict): + ret = {self._decode_hash_key(key): self._deserialize_value(value) for key, value in ret.items()} if lower_method in EXPIRE_METHOD and command.args: if not command.expire.key: command.expire.key = command.args[0] diff --git a/trpc_agent_sdk/storage/_redis_cluster.py b/trpc_agent_sdk/storage/_redis_cluster.py index 7b9db5b5e..70ed2e5f1 100644 --- a/trpc_agent_sdk/storage/_redis_cluster.py +++ b/trpc_agent_sdk/storage/_redis_cluster.py @@ -141,7 +141,7 @@ async def execute_command(self, conn: RedisClusterClient, command: RedisCommand) result = await super().execute_command(conn, command) if lower_method == "hgetall" and isinstance(result, dict): - return {self._decode_text(k): self._decode_text(v) for k, v in result.items()} + return {self._decode_text(k): self._deserialize_value(v) for k, v in result.items()} return result async def close(self) -> None: