diff --git a/api/routes.py b/api/routes.py index 7e5e7b83..a3405c92 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6787,20 +6787,30 @@ def _pre_compression_continuation_session_id(session) -> str | None: return None if not isinstance(entries, list): return None + try: + persisted_sidecar_ids = { + path.stem + for path in SESSION_DIR.glob("*.json") + if not path.name.startswith("_") and is_safe_session_id(path.stem) + } + except Exception: + return None + indexed_ids: set[str] = set() + row_seen_ids = set(seen_ids) rows = [] for entry in entries: if not isinstance(entry, dict): continue child_sid = _safe_first(entry.get("session_id")) - if ( - not child_sid - or child_sid in seen_ids - or not is_safe_session_id(child_sid) - or not _safe_first(entry.get("parent_session_id")) - ): + if not child_sid or not is_safe_session_id(child_sid): continue - seen_ids.add(child_sid) + indexed_ids.add(child_sid) + if child_sid in row_seen_ids or not _safe_first(entry.get("parent_session_id")): + continue + row_seen_ids.add(child_sid) rows.append(entry) + if persisted_sidecar_ids - indexed_ids - seen_ids: + return None return rows def _child_rows_from_sidecars(seen_ids: set[str]) -> list: @@ -6880,14 +6890,9 @@ def _pre_compression_continuation_session_id(session) -> str | None: memory_seen_ids: set[str] = set() rows = _child_rows_from_memory(memory_seen_ids) - index_seen_ids = set(memory_seen_ids) - index_rows = _child_rows_from_index(index_seen_ids) + index_rows = _child_rows_from_index(memory_seen_ids) if index_rows is not None: - indexed_result = _resolve_from_rows(rows + index_rows) - if indexed_result: - return indexed_result - rows.extend(_child_rows_from_sidecars(set(memory_seen_ids))) - return _resolve_from_rows(rows) + return _resolve_from_rows(rows + index_rows) rows.extend(_child_rows_from_sidecars(memory_seen_ids)) return _resolve_from_rows(rows) diff --git a/tests/test_mobile_reload_compression_recovery.py b/tests/test_mobile_reload_compression_recovery.py index 07e332cc..bc5d31f0 100644 --- a/tests/test_mobile_reload_compression_recovery.py +++ b/tests/test_mobile_reload_compression_recovery.py @@ -10,15 +10,16 @@ SESSIONS_JS = ROOT / "static" / "sessions.js" def _write_sidecar(session_dir: Path, sid: str, **overrides): + messages = overrides.pop("messages", []) payload = { "session_id": sid, "title": sid, "created_at": 100.0, "updated_at": 100.0, "profile": "work", - "messages": [], } payload.update(overrides) + payload["messages"] = messages (session_dir / f"{sid}.json").write_text(json.dumps(payload), encoding="utf-8") @@ -128,6 +129,7 @@ def test_continuation_lookup_uses_index_without_scanning_sidecars(tmp_path, monk "created_at": 200.0, } ] + + [{"session_id": f"noise{idx:08d}", "profile": "work"} for idx in range(50)] ), encoding="utf-8", ) @@ -182,6 +184,128 @@ def test_empty_indexed_continuation_lookup_falls_back_to_sidecars(tmp_path, monk assert loaded == ["childempty01"] +def test_stale_index_with_existing_candidate_falls_back_to_newer_sidecar(tmp_path, monkeypatch): + """A complete-looking result is not trusted when another sidecar is absent from the index.""" + from api import routes, config, models + + class _S: + def __init__(self, sid, profile, snap=False): + self.session_id = sid + self.profile = profile + self.parent_session_id = None + self.pre_compression_snapshot = snap + self.updated_at = 100.0 + self.created_at = 100.0 + + snapshot = _S("snapstale001", "work", snap=True) + index_file = tmp_path / "_index.json" + _write_sidecar( + tmp_path, + "oldstale001", + parent_session_id="snapstale001", + updated_at=200.0, + created_at=150.0, + ) + _write_sidecar( + tmp_path, + "newstale001", + parent_session_id="snapstale001", + updated_at=400.0, + created_at=350.0, + ) + index_file.write_text( + json.dumps( + [ + { + "session_id": "oldstale001", + "profile": "work", + "parent_session_id": "snapstale001", + "updated_at": 200.0, + "created_at": 150.0, + } + ] + ), + encoding="utf-8", + ) + + monkeypatch.setattr(config, "SESSION_DIR", tmp_path, raising=False) + monkeypatch.setattr(models, "SESSION_DIR", tmp_path, raising=False) + monkeypatch.setattr(routes, "SESSION_DIR", tmp_path, raising=False) + monkeypatch.setattr(routes, "SESSION_INDEX_FILE", index_file, raising=False) + monkeypatch.setattr(routes, "SESSIONS", collections.OrderedDict(), raising=False) + + assert routes._pre_compression_continuation_session_id(snapshot) == "newstale001" + + +def test_stale_index_multihop_falls_back_to_missing_descendant_sidecar(tmp_path, monkeypatch): + """An indexed snapshot ancestor must not hide a newer descendant omitted from the index.""" + from api import routes, config, models + + class _S: + def __init__(self, sid, profile, snap=False): + self.session_id = sid + self.profile = profile + self.parent_session_id = None + self.pre_compression_snapshot = snap + self.updated_at = 100.0 + self.created_at = 100.0 + + snapshot = _S("snapstale002", "work", snap=True) + index_file = tmp_path / "_index.json" + _write_sidecar( + tmp_path, + "oldstale002", + parent_session_id="snapstale002", + updated_at=200.0, + created_at=150.0, + ) + _write_sidecar( + tmp_path, + "midstale002", + parent_session_id="snapstale002", + pre_compression_snapshot=True, + updated_at=300.0, + created_at=250.0, + ) + _write_sidecar( + tmp_path, + "newstale002", + parent_session_id="midstale002", + updated_at=500.0, + created_at=450.0, + ) + index_file.write_text( + json.dumps( + [ + { + "session_id": "oldstale002", + "profile": "work", + "parent_session_id": "snapstale002", + "updated_at": 200.0, + "created_at": 150.0, + }, + { + "session_id": "midstale002", + "profile": "work", + "parent_session_id": "snapstale002", + "pre_compression_snapshot": True, + "updated_at": 300.0, + "created_at": 250.0, + }, + ] + ), + encoding="utf-8", + ) + + monkeypatch.setattr(config, "SESSION_DIR", tmp_path, raising=False) + monkeypatch.setattr(models, "SESSION_DIR", tmp_path, raising=False) + monkeypatch.setattr(routes, "SESSION_DIR", tmp_path, raising=False) + monkeypatch.setattr(routes, "SESSION_INDEX_FILE", index_file, raising=False) + monkeypatch.setattr(routes, "SESSIONS", collections.OrderedDict(), raising=False) + + assert routes._pre_compression_continuation_session_id(snapshot) == "newstale002" + + def test_indexed_continuation_lookup_follows_snapshot_hops_without_scanning(tmp_path, monkeypatch): """Repeated compression can resolve through index-backed snapshot descendants.""" from api import routes, config