Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison b4fc157c13 fix(session): forward overwrite kwarg in SqliteSessionStore import override
SqliteSessionStore._save_imported_session did not accept the overwrite
keyword that DefaultSessionStore.import_sessions forwards, raising a
TypeError on every SQLite import. Mirror the base signature and propagate
overwrite. Also update the stale delayed_save test shim to match.

Co-authored-by: Mervin Praison <[email protected]>
2026-10-02 13:01:08 +00:00
Wu Shuwen a07c42ec45 Verify archives survive hierarchy reload and mutation 2026-10-02 12:53:42 +01:00
Wu Shuwen 2f7133781d Preserve the full base session schema in hierarchy conversions 2026-10-02 12:53:42 +01:00
4 changed files with 73 additions and 28 deletions

No files matched your search

@@ -13,7 +13,7 @@ import os
import threading
import time
import uuid
from dataclasses import dataclass, field
from dataclasses import dataclass, field, fields
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@@ -78,27 +78,13 @@ class ExtendedSessionData(SessionData):
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ExtendedSessionData":
messages = [
SessionMessage.from_dict(m)
for m in data.get("messages", [])
]
archived = [
SessionMessage.from_dict(m)
for m in (data.get("archived_messages") or [])
]
base = SessionData.from_dict(data)
snapshots = [
SessionSnapshot.from_dict(s)
for s in data.get("snapshots", [])
]
return cls(
session_id=data.get("session_id", ""),
messages=messages,
created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()),
updated_at=data.get("updated_at", datetime.now(timezone.utc).isoformat()),
agent_name=data.get("agent_name"),
user_id=data.get("user_id"),
metadata=data.get("metadata", {}),
archived_messages=archived,
**{descriptor.name: getattr(base, descriptor.name) for descriptor in fields(SessionData)},
parent_id=data.get("parent_id"),
forked_from_message_id=data.get("forked_from_message_id"),
children_ids=data.get("children_ids", []),
@@ -111,13 +97,7 @@ class ExtendedSessionData(SessionData):
def from_session_data(cls, session: SessionData) -> "ExtendedSessionData":
"""Convert a basic SessionData to ExtendedSessionData."""
return cls(
session_id=session.session_id,
messages=session.messages,
created_at=session.created_at,
updated_at=session.updated_at,
agent_name=session.agent_name,
user_id=session.user_id,
metadata=session.metadata,
**{descriptor.name: getattr(session, descriptor.name) for descriptor in fields(SessionData)},
)
class HierarchicalSessionStore(DefaultSessionStore):
@@ -370,9 +370,9 @@ class SqliteSessionStore(DefaultSessionStore):
self._index_session(session)
return ok
def _save_imported_session(self, session: SessionData) -> bool:
def _save_imported_session(self, session: SessionData, *, overwrite: bool = True) -> bool:
"""Refresh recall and routing after a verbatim portable restore."""
ok = super()._save_imported_session(session)
ok = super()._save_imported_session(session, overwrite=overwrite)
if ok:
try:
filepath = self._get_session_path(session.session_id)
@@ -0,0 +1,65 @@
"""Hierarchy deserialization retains the complete base session schema."""
from dataclasses import fields
import pytest
from praisonaiagents.session.hierarchy import ExtendedSessionData, HierarchicalSessionStore
from praisonaiagents.session.store import DefaultSessionStore, SessionData
@pytest.fixture
def base_session(tmp_path):
store = DefaultSessionStore(session_dir=str(tmp_path), active_window=3)
assert store.add_message("session", "user", "archived opening")
for index in range(8):
assert store.add_message("session", "user", f"later turn {index}")
assert store.set_gateway_info("session", gateway_session_id="gateway", agent_id="agent")
assert store.set_runtime_state("session", "native", "turn", {"status": "saved"})
assert store.append_compaction_checkpoint("session", "resume summary", tokens_before=20, tokens_after=5)
session = store.get_session("session")
assert session.archived_messages
return store, session
@pytest.mark.parametrize("operation", ["read", "set_title", "add_message"])
@pytest.mark.parametrize("field_name", ["gateway_session_id", "agent_id", "runtime_state", "last_compaction", "archived_messages"])
def test_hierarchy_load_and_mutation_keep_base_fields(base_session, operation, field_name):
plain, before = base_session
# Preserve the existing archive without triggering a new compaction whose
# legitimate anchor adjustment would obscure the deserialization contract.
hierarchy = HierarchicalSessionStore(session_dir=plain.session_dir, active_window=100)
if operation == "set_title":
assert hierarchy.set_title("session", "Hierarchy title")
elif operation == "add_message":
assert hierarchy.add_message("session", "assistant", "new turn")
after = hierarchy.get_extended_session("session", force_reload=True)
assert getattr(after, field_name) == getattr(before, field_name)
plain.invalidate_cache()
assert getattr(plain.get_session("session"), field_name) == getattr(before, field_name)
def test_conversion_from_base_session_keeps_every_base_field(base_session):
_, before = base_session
after = ExtendedSessionData.from_session_data(before)
for descriptor in fields(SessionData):
assert getattr(after, descriptor.name) == getattr(before, descriptor.name), descriptor.name
def test_hierarchy_parser_keeps_legacy_metadata_and_precedence():
payload = {"session_id": "legacy", "model": "recorded-model", "reasoning_effort": "high",
"metadata": {"model": "preferred-model"}}
base = SessionData.from_dict(payload)
extended = ExtendedSessionData.from_dict(payload)
assert extended.metadata == base.metadata == {"model": "preferred-model", "reasoning_effort": "high"}
assert payload["metadata"] == {"model": "preferred-model"}
def test_extended_fields_still_round_trip():
payload = {"session_id": "extended", "parent_id": "parent", "forked_from_message_id": "2",
"children_ids": ["child"], "is_shared": True, "title": "Title",
"snapshots": [{"id": "snapshot", "session_id": "extended", "message_index": 2}]}
after = ExtendedSessionData.from_dict(payload).to_dict()
for name in ("parent_id", "forked_from_message_id", "children_ids", "is_shared", "title"):
assert after[name] == payload[name]
assert after["snapshots"][0]["id"] == "snapshot"
@@ -111,8 +111,8 @@ def test_write_between_import_save_and_index_keeps_newer_content(make_store, mon
resume = threading.Event()
original = DefaultSessionStore._save_imported_session
def delayed_save(store, session):
result = original(store, session)
def delayed_save(store, session, *, overwrite=True):
result = original(store, session, overwrite=overwrite)
saved.set()
assert resume.wait(5), "concurrent writer did not finish"
return result