diff --git a/frontends/continue_cmd.py b/frontends/continue_cmd.py index 3e926c11..73664c07 100644 --- a/frontends/continue_cmd.py +++ b/frontends/continue_cmd.py @@ -832,6 +832,11 @@ def extract_ui_messages(path): try: with open(path, encoding='utf-8', errors='replace') as f: content = f.read() except Exception: return [] + return extract_ui_messages_text(content) + + +def extract_ui_messages_text(content): + """Replay log text, including archive members, without extracting to disk.""" pairs = _pairs(content) if not pairs: return [] # tool_results live in the *next* Prompt's content; index look-ahead. @@ -839,16 +844,21 @@ def extract_ui_messages(path): for i in range(len(pairs) - 1): next_tr[i] = _tool_results_from_prompt(pairs[i + 1][0]) + stamps = re.findall(r'^=== Prompt ===\s+(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)', content, re.M) out, assistant, round_turn = [], None, 0 for i, (prompt, response) in enumerate(pairs): + try: + ts = time.mktime(time.strptime(stamps[i], '%Y-%m-%d %H:%M:%S')) + except (IndexError, ValueError, OverflowError): + ts = None user = _user_text(prompt) seg = _format_response_segment(response, next_tr[i]) if user: if assistant is not None: out.append(assistant) - out.append({'role': 'user', 'content': user}) + out.append({'role': 'user', 'content': user, 'ts': ts}) # Turn 1 marker too — agent_loop yields one per LLM call, including the # first, so fold_turns treats every non-last call uniformly as a fold. - assistant = {'role': 'assistant', + assistant = {'role': 'assistant', 'ts': ts, 'content': f"\n\n**LLM Running (Turn 1) ...**\n\n{seg}"} round_turn = 1 else: diff --git a/frontends/data_backup.py b/frontends/data_backup.py index 10e83f99..97241298 100644 --- a/frontends/data_backup.py +++ b/frontends/data_backup.py @@ -430,15 +430,19 @@ def canonical_session_record( plan_path = item.get("plan_path", "") if not isinstance(title, str) or not isinstance(cwd, str) or not isinstance(plan_path, str): raise BackupFormatError("session text field is invalid") - messages = _record_list(item.get("messages"), [], "messages") - llm_history_value = item.get("llm_history") - llm_history = None if llm_history_value is None else _record_list( - llm_history_value, [], "llm history" - ) + try: + from .desktop_history import metadata + except ImportError: + from desktop_history import metadata llm_no_value = item.get("llm_no") llm_no = None if llm_no_value is None else _non_negative_integer( llm_no_value, 0, "model index" ) + if "messages" in item: + _record_list(item["messages"], [], "messages") + history = item.get("llm_history") + if history is not None and (not isinstance(history, list) or any(not isinstance(m, dict) for m in history)): + raise BackupFormatError("session history is invalid") pinned = item.get("pinned", False) untitled = item.get("untitled", True) if not isinstance(pinned, bool) or not isinstance(untitled, bool): @@ -449,7 +453,7 @@ def canonical_session_record( "cwd": cwd, "created_at": _finite_timestamp(item.get("created_at"), current_time), "updated_at": _finite_timestamp(item.get("updated_at"), current_time), - "messages": messages, + **metadata(item), "msg_seq": _non_negative_integer(item.get("msg_seq"), 0, "message sequence"), "pinned": pinned, "untitled": untitled, @@ -458,7 +462,6 @@ def canonical_session_record( ), "plan_path": plan_path, "llm_no": llm_no, - "llm_history": llm_history, } diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py index d3c96748..340c76e8 100644 --- a/frontends/desktop_bridge.py +++ b/frontends/desktop_bridge.py @@ -250,7 +250,11 @@ class Session: untitled: bool = True plan_scan_baseline: int = 0 plan_path: str = "" - llm_history: Optional[List[dict]] = None + llm_history: Optional[List[dict]] = None # memory only; reconstructed from MR/L4 + first_message: str = "" + first_message_at: float = 0 + log_refs: List[dict] = field(default_factory=list) + has_content: bool = False # 该会话绑定的模型下标(mykey.py 配置块顺序,== agent.llmclients 下标)。 # None = 未绑定,发消息时回退到全局默认 ui.llmNo,保持旧会话平滑迁移。 llm_no: Optional[int] = None @@ -334,20 +338,16 @@ class AgentManager: return str(Path(self.ga_root) / "mykey.py") def _session_dict(self, s: "Session") -> dict: - llm_hist = None - if s.agent and hasattr(s.agent, 'llmclient'): - try: llm_hist = s.agent.llmclient.backend.history - except Exception: pass - if llm_hist is None: - llm_hist = s.llm_history + from desktop_history import metadata return {"id": s.id, "title": s.title, "cwd": s.cwd, "created_at": s.created_at, "updated_at": s.updated_at, - "messages": s.messages, "msg_seq": s.msg_seq, - "pinned": s.pinned, "untitled": s.untitled, + "msg_seq": s.msg_seq, "pinned": s.pinned, "untitled": s.untitled, "plan_scan_baseline": s.plan_scan_baseline, - "plan_path": s.plan_path or "", - "llm_no": s.llm_no, - "llm_history": llm_hist} + "plan_path": s.plan_path or "", "llm_no": s.llm_no, + **metadata({"first_message": s.first_message, + "first_message_at": s.first_message_at, + "log_refs": s.log_refs, "has_content": s.has_content, + "messages": s.messages})} def _session_file(self, sid: str) -> Path: return self._sessions_dir / f"{sid}.json" @@ -425,9 +425,6 @@ class AgentManager: self._sessions_dir.mkdir(parents=True, exist_ok=True) with self.lock: data = self._session_dict(s) - data["messages"] = copy.deepcopy(data["messages"]) - if data.get("llm_history"): - data["llm_history"] = copy.deepcopy(data["llm_history"]) tmp.write_text(json.dumps(data, ensure_ascii=False, default=str), encoding="utf-8") os.replace(tmp, self._session_file(s.id)) except Exception as e: @@ -452,22 +449,23 @@ class AgentManager: for s in sessions: self._persist_session(s, strict=strict) - def _session_from_item(self, item: dict) -> "Session": + def _session_from_item(self, item: dict) -> Optional["Session"]: + from desktop_history import legacy_anchors, load + anchors = legacy_anchors(item) item = canonical_session_record(item, default_cwd=self.ga_root) - msgs = item["messages"] - return Session(id=item["id"], title=item["title"], - cwd=item["cwd"], - created_at=item["created_at"], - updated_at=item["updated_at"], - messages=msgs, - msg_seq=item["msg_seq"], - pinned=item["pinned"], - untitled=item["untitled"], - plan_scan_baseline=_load_plan_baseline(item, msgs), + restored = load(self.ga_root, item, anchors) + if restored is None: + return None + msgs, history, meta = restored + return Session(id=item["id"], title=item["title"], cwd=item["cwd"], + created_at=item["created_at"], updated_at=item["updated_at"], + messages=msgs, msg_seq=max(item["msg_seq"], len(msgs)), + pinned=item["pinned"], untitled=item["untitled"], + plan_scan_baseline=min(item["plan_scan_baseline"], len(msgs)), plan_path=_sanitize_desktop_plan_path(item["id"], item["plan_path"]), - status="idle", agent=None, - llm_history=item["llm_history"], - llm_no=item["llm_no"]) + status="idle", agent=None, llm_history=history, llm_no=item["llm_no"], + first_message=meta["first_message"], first_message_at=meta["first_message_at"], + log_refs=meta["log_refs"], has_content=meta["has_content"]) def _load_sessions(self): # New format: one file per session under temp/desktop_sessions/. @@ -479,7 +477,11 @@ class AgentManager: if not _is_desktop_session_id(item.get("id")): continue sess = self._session_from_item(item) + if sess is None: + f.write_text(json.dumps(canonical_session_record(item), ensure_ascii=False), encoding="utf-8") + continue self.sessions[sess.id] = sess + self._persist_session(sess) except Exception as e: bridge_print(f"[bridge] load session {f.name} failed: {e}") except Exception as e: @@ -496,14 +498,15 @@ class AgentManager: continue try: sess = self._session_from_item(item) + if sess is None: + continue self.sessions[sess.id] = sess self._persist_session(sess) except Exception as e: bridge_print(f"[bridge] migrate session failed: {e}") # Retire the legacy file so we do not migrate again next launch. with contextlib.suppress(Exception): - self._sessions_file.rename( - self._sessions_file.parent / (self._sessions_file.name + ".migrated")) + self._sessions_file.unlink() except Exception as e: bridge_print(f"[bridge] migrate sessions failed: {e}") @@ -559,6 +562,9 @@ class AgentManager: except ValueError: skipped += 1 continue + if sess is None: + skipped += 1 + continue self.sessions[sid] = sess new_sessions.append(sess) added += 1 @@ -752,6 +758,9 @@ class AgentManager: no = sess.llm_no if sess.llm_no is not None else _global_default_llm_no() if no is not None: agent.next_llm(int(no)) + if sess.llm_history: + from continue_cmd import _derive_hist_info, _load_history_into + _load_history_into(agent, copy.deepcopy(sess.llm_history), _derive_hist_info(sess.llm_history)) agent.inc_out = True agent.verbose = True threading.Thread(target=agent.run, daemon=True, name=f"GA-{sess.id}").start() @@ -924,6 +933,12 @@ class AgentManager: msg = {"id": sess.msg_seq, "role": role, "content": content, "ts": time.time()} msg.update(extra) sess.messages.append(msg) + if role == "user": + from desktop_history import preview + if not sess.first_message: + sess.first_message = preview(content) + sess.first_message_at = msg["ts"] + sess.has_content = True sess.updated_at = time.time() if role == "user" and content.strip() and sess.title == "New chat": sess.title = content.strip().replace("\n", " ")[:40] @@ -1085,13 +1100,14 @@ class AgentManager: return if sess.agent is None: sess.agent = self.make_agent(sess) - if sess.llm_history: - try: - sess.agent.llmclient.backend.history = sess.llm_history - except Exception: - pass agent = sess.agent + from desktop_history import preview + log_path = getattr(agent, "log_path", None) with self.lock: + if log_path and not any(r["name"] == Path(log_path).name for r in sess.log_refs): + sess.log_refs.append({"name": Path(log_path).name, + "started_at": time.time(), "first_message": preview(prompt)}) + self._persist_session(sess) sess.running_llm_no = agent.llm_no try: running_backend = agent.llmclient.backend @@ -1290,25 +1306,6 @@ class AgentManager: if no is not None and hasattr(agent, "next_llm"): with contextlib.suppress(Exception): agent.next_llm(int(no)) - if sess.llm_history: - try: - agent.llmclient.backend.history = sess.llm_history - except Exception as e: - bridge_print(f"[bridge] restore llm_history failed: {e}") - else: - history = [] - for m in sess.messages: - role = m.get("role") - content = m.get("content", "") - if role == "user": - history.append({"role": "user", "content": [{"type": "text", "text": content}]}) - elif role == "assistant": - history.append({"role": "assistant", "content": [{"type": "text", "text": content}]}) - if history: - try: - agent.llmclient.backend.history = history - except Exception as e: - bridge_print(f"[bridge] inject history failed: {e}") with self.lock: sess.agent = agent sess.status = "idle" diff --git a/frontends/desktop_history.py b/frontends/desktop_history.py new file mode 100644 index 00000000..0ceb65fc --- /dev/null +++ b/frontends/desktop_history.py @@ -0,0 +1,185 @@ +"""Desktop metadata and MR/L4 replay. Never writes conversation bodies.""" +from __future__ import annotations + +import datetime as dt +import math +from pathlib import Path +import re +import zipfile + +try: + from .continue_cmd import _pairs, _parse_native_history, _user_text, extract_ui_messages_text +except ImportError: + from continue_cmd import _pairs, _parse_native_history, _user_text, extract_ui_messages_text + +_HEADER = re.compile(r'^=== Prompt ===\s+(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)', re.M) +_LOG_NAME = re.compile(r'model_responses_[\w-]+\.txt\Z') + + +def _stamp(value, default=0): + try: + value = float(value) + return value if math.isfinite(value) and 0 <= value <= 253402214400 else default + except (TypeError, ValueError): + return default + + +def preview(value): + return value.strip()[:256] if isinstance(value, str) else '' + + +def metadata(item): + """Bounded text anchors only; legacy message bodies are used but never returned.""" + users = [m for m in item.get('messages', []) if isinstance(m, dict) and m.get('role') == 'user'] + first = users[0] if users else {} + refs = [] + for ref in item.get('log_refs', []) or []: + if not isinstance(ref, dict): + continue + name = ref.get('name', '') + refs.append({'name': name if isinstance(name, str) and _LOG_NAME.fullmatch(name) else '', + 'started_at': _stamp(ref.get('started_at')), + 'first_message': preview(ref.get('first_message'))}) + return {'storage_version': 2, + 'first_message': preview(item.get('first_message') or first.get('content')), + 'first_message_at': _stamp(item.get('first_message_at'), _stamp(first.get('ts'), _stamp(item.get('created_at')))), + 'log_refs': refs, + 'has_content': bool(item.get('has_content') or users or item.get('llm_history') or refs)} + + +def legacy_anchors(item): + """Old Desktop versions could create multiple MR files after restarts.""" + return [{'name': '', 'started_at': _stamp(m.get('ts'), _stamp(item.get('created_at'))), + 'first_message': preview(m.get('content'))} + for m in item.get('messages', []) + if isinstance(m, dict) and m.get('role') == 'user' and preview(m.get('content'))] + + +def _first_time(text): + m = _HEADER.search(text) + if m: + try: + return dt.datetime.strptime(m[1], '%Y-%m-%d %H:%M:%S').timestamp() + except (ValueError, OverflowError, OSError): + pass + return 0 + + +def _matches(text, ref): + started = _first_time(text) + expected = ref.get('started_at', 0) + if not started or not expected or abs(started - expected) > 300: + return False + anchor = preview(ref.get('first_message')) + if not anchor: + return False + pairs = _pairs(text) + if not pairs: + return False + # Native prompts may prepend attachment paths; raw archives contain USER markers. + prompt = _user_text(pairs[0][0]) or pairs[0][0] + return anchor in prompt + + +def resolve(root, ref): + """MR first, then time/first-message matching in L4. ZIP reads stay in memory.""" + root = Path(root) + mr = root / 'temp' / 'model_responses' + name = ref.get('name', '') + direct = mr / name if name and _LOG_NAME.fullmatch(name) else None + candidates = ([direct] if direct and direct.is_file() else []) + candidates += [p for p in mr.glob('model_responses_*.txt') if p != direct] + for path in candidates: + try: + with path.open(encoding='utf-8', errors='replace') as f: + head = f.read(65536) + started = _first_time(head) + if not started or abs(started - ref.get('started_at', 0)) > 300: + continue + text = head + f.read() + if _matches(text, ref): + return text, {**ref, 'name': path.name, 'started_at': started} + except OSError: + continue + stamp = ref.get('started_at', 0) + if not stamp: + return None + l4 = root / 'memory' / 'L4_raw_sessions' + # Month/minute names avoid scanning/decompressing the full archive corpus. + moment = dt.datetime.fromtimestamp(stamp) + moments = [moment + dt.timedelta(minutes=i) for i in range(-5, 6)] + prefixes = {t.strftime('%m%d_%H%M') for t in moments} + months = {t.strftime('%Y-%m') for t in moments} + for month in sorted(months): + archive = l4 / (month + '.zip') + try: + with zipfile.ZipFile(archive) as z: + for member in z.infolist(): + if Path(member.filename).name[:9] not in prefixes or not member.filename.endswith('.txt'): + continue + text = z.read(member).decode('utf-8', errors='replace') + if _matches(text, ref): + return text, {**ref, 'started_at': _first_time(text)} + except (OSError, zipfile.BadZipFile, RuntimeError): + continue + for path in l4.glob('*.txt'): + if path.name[:9] in prefixes: + try: + text = path.read_text(encoding='utf-8', errors='replace') + if _matches(text, ref): + return text, {**ref, 'started_at': _first_time(text)} + except OSError: + pass + return None + + +def replay(text): + pairs = _pairs(text) + native = _parse_native_history(pairs) if pairs else None + if native is not None: + return extract_ui_messages_text(text), native + # Raw logs / stripped L4: replay preserved prose; continuation is semantic. + messages = [] + for prompt, response in pairs: + match = re.search(r'^=== USER ===\s*\n(.*?)(?=^=== ASSISTANT ===|\Z)', prompt, re.M | re.S) + user = match[1].strip() if match else '' + if user: + messages.append({'role': 'user', 'content': user}) + if response.strip(): + messages.append({'role': 'assistant', 'content': response.strip()}) + return messages, None + + +def load(root, item, anchors=None): + """Return replay and small updated index, or None when every source is gone.""" + meta = metadata(item) + refs = meta['log_refs'] or anchors or [ + {'name': '', 'started_at': meta['first_message_at'], 'first_message': meta['first_message']}] + messages, history, found, seen = [], [], [], set() + semantic = False + for ref in refs: + result = resolve(root, ref) + if result is None: + continue + text, located = result + identity = (located.get('name'), _first_time(text)) + if identity in seen: + continue + seen.add(identity) + ui, native = replay(text) + if not ui: + continue + found.append(located) + messages.extend({**m, 'ts': located['started_at']} for m in ui) + if native is None: + semantic = True + else: + history.extend(native) + if not found and meta['has_content']: + return None + if semantic: + history = [{'role': m['role'], 'content': [{'type': 'text', 'text': m['content']}]} for m in messages] + for i, message in enumerate(messages, 1): + message['id'] = i + meta['log_refs'] = found or meta['log_refs'] + return messages, history or None, meta diff --git a/frontends/tests/test_bridge_sessions.py b/frontends/tests/test_bridge_sessions.py index de1a6525..91610d6e 100644 --- a/frontends/tests/test_bridge_sessions.py +++ b/frontends/tests/test_bridge_sessions.py @@ -147,7 +147,8 @@ class TestPersistSessionConcurrentMutation: f = manager._session_file(sess.id) data = json.loads(f.read_text(encoding="utf-8")) assert data["id"] == sess.id - assert isinstance(data["messages"], list) + assert "messages" not in data + assert data["storage_version"] == 2 def test_concurrent_mutation_llm_history(self, manager: AgentManager): """Mutating llm_history from another thread during persist must not corrupt.""" @@ -176,7 +177,7 @@ class TestPersistSessionConcurrentMutation: f = manager._session_file(sess.id) data = json.loads(f.read_text(encoding="utf-8")) - assert isinstance(data["llm_history"], list) + assert "llm_history" not in data class TestTransactionalDataImport: @@ -563,6 +564,12 @@ class TestSessionContinuityAfterRestart: def test_persist_and_reload_preserves_llm_no(self, manager: AgentManager): """Full cycle: persist session with llm_no, reload, verify llm_no survives.""" sess = _make_session(sid="sess-roundtrip") + from test_desktop_history import seed_mr, record + seed_mr(Path(manager.ga_root), "model_responses_roundtrip.txt") + meta = record("model_responses_roundtrip.txt") + sess.log_refs = meta["log_refs"] + sess.first_message = meta["first_message"] + sess.first_message_at = meta["first_message_at"] sess.llm_no = 5 sess.llm_history = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] manager.sessions[sess.id] = sess @@ -574,7 +581,8 @@ class TestSessionContinuityAfterRestart: reloaded = manager.sessions.get("sess-roundtrip") assert reloaded is not None assert reloaded.llm_no == 5 - assert reloaded.llm_history == [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + assert reloaded.llm_history[0]["content"][0]["text"] == "hello" + assert len(reloaded.llm_history) == 2 assert reloaded.agent is None @@ -1311,6 +1319,9 @@ class TestCanonicalSessionRestart: "unknown": "ignored", }, } + from test_desktop_history import seed_mr, record as log_record + seed_mr(Path(manager.ga_root), "model_responses_canonical.txt") + records["valid.json"].update(log_record("model_responses_canonical.txt")) for name, record in records.items(): (manager._sessions_dir / name).write_text(json.dumps(record), encoding="utf-8") diff --git a/frontends/tests/test_data_backup.py b/frontends/tests/test_data_backup.py index 6afd309c..da2f21f4 100644 --- a/frontends/tests/test_data_backup.py +++ b/frontends/tests/test_data_backup.py @@ -464,7 +464,9 @@ class TestDataBackupImport: ) assert document["id"] == "sess-valid" assert document["updated_at"] == 11.5 - assert document["messages"][0]["role"] == "user" + assert "messages" not in document + assert "llm_history" not in document + assert document["storage_version"] == 2 assert "unknown" not in document def test_mocked_windows_reparse_source_is_rejected_before_read( diff --git a/frontends/tests/test_desktop_history.py b/frontends/tests/test_desktop_history.py new file mode 100644 index 00000000..82e42567 --- /dev/null +++ b/frontends/tests/test_desktop_history.py @@ -0,0 +1,157 @@ +"""Metadata-only persistence and MR -> L4 recovery contract.""" +import datetime as dt +import json +from pathlib import Path +import zipfile + +from frontends import desktop_history as h +from frontends.data_backup import canonical_session_record + +STAMP = dt.datetime(2026, 10, 17, 10, 0).timestamp() + + +def log_text(user='hello', answer='world', stamp=STAMP): + moment = dt.datetime.fromtimestamp(stamp).strftime('%Y-%m-%d %H:%M:%S') + prompt = json.dumps({'role': 'user', 'content': [{'type': 'text', 'text': user}]}) + response = repr([{'type': 'text', 'text': answer}]) + return f'=== Prompt === {moment}\n{prompt}\n=== Response === {moment}\n{response}\n' + + +def seed_mr(root, name='model_responses_test.txt', **kwargs): + path = root / 'temp' / 'model_responses' / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(log_text(**kwargs), encoding='utf-8') + return path + + +def record(name='model_responses_test.txt', user='hello', stamp=STAMP): + return {'first_message': user, 'first_message_at': stamp, 'has_content': True, + 'log_refs': [{'name': name, 'started_at': stamp, 'first_message': user}]} + + +def seed_zip(root, text, stamp=STAMP): + moment = dt.datetime.fromtimestamp(stamp) + path = root / 'memory' / 'L4_raw_sessions' / (moment.strftime('%Y-%m') + '.zip') + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z: + z.writestr(moment.strftime('%m%d_%H%M') + '-1017_1001.txt', text) + return path + + +def test_canonical_never_persists_bodies(): + data = canonical_session_record({'id': 'sess-test', 'messages': [ + {'role': 'user', 'content': 'x' * 10000, 'ts': STAMP}, + {'role': 'assistant', 'content': 'PRIVATE_BODY', 'turn_segs': ['PRIVATE_BODY']}], + 'llm_history': [{'role': 'user', 'content': 'PRIVATE_HISTORY'}]}) + assert len(data['first_message']) == 256 + assert data['first_message_at'] == STAMP + assert 'messages' not in data and 'llm_history' not in data + assert 'PRIVATE' not in json.dumps(data) + assert len(json.dumps(data)) < 1200 + + +def test_mr_wins_over_archive(tmp_path): + seed_mr(tmp_path, answer='MR answer') + seed_zip(tmp_path, log_text(answer='L4 answer')) + ui, history, meta = h.load(tmp_path, record()) + assert 'MR answer' in ui[-1]['content'] + assert history[-1]['content'][0]['text'] == 'MR answer' + assert meta['log_refs'][0]['name'] == 'model_responses_test.txt' + + +def test_l4_zip_replay_never_extracts(tmp_path): + seed_zip(tmp_path, log_text()) + before = {p for p in tmp_path.rglob('*') if p.is_file()} + ui, history, _ = h.load(tmp_path, record()) + assert ui[0]['content'] == 'hello' + assert 'world' in ui[-1]['content'] + assert len(history) == 2 + assert before == {p for p in tmp_path.rglob('*') if p.is_file()} + + +def test_raw_l4_semantic_recovery(tmp_path): + text = ('=== Prompt === 2026-10-17 10:00:00\n=== USER ===\nhello\n' + '=== Response === 2026-10-17 10:00:01\nraw answer\n') + seed_zip(tmp_path, text) + ui, history, _ = h.load(tmp_path, record()) + assert ui[-1]['content'] == 'raw answer' + assert history[0]['content'][0]['text'] == 'hello' + + +def test_missing_sources_silently_skip_nonempty(tmp_path): + assert h.load(tmp_path, record()) is None + assert h.load(tmp_path, {'id': 'empty'})[0] == [] + + +def test_wrong_time_or_first_message_is_not_another_session(tmp_path): + seed_mr(tmp_path) + assert h.load(tmp_path, record(user='other')) is None + assert h.load(tmp_path, record(stamp=STAMP + 3600)) is None + + +def test_legacy_metadata_discovers_mr(tmp_path): + seed_mr(tmp_path) + item = {'messages': [{'role': 'user', 'content': 'hello', 'ts': STAMP}]} + result = h.load(tmp_path, item, h.legacy_anchors(item)) + assert result[2]['log_refs'][0]['name'] == 'model_responses_test.txt' + + +def test_multiple_segments_partial_missing_and_dedup(tmp_path): + seed_mr(tmp_path) + seed_mr(tmp_path, 'model_responses_next.txt', user='next', stamp=STAMP + 3600) + item = record() + item['log_refs'] += record('model_responses_next.txt', 'next', STAMP + 3600)['log_refs'] + item['log_refs'] += record('model_responses_missing.txt', 'gone', STAMP + 7200)['log_refs'] + item['log_refs'].append(item['log_refs'][0]) + ui, history, meta = h.load(tmp_path, item) + assert [m['content'] for m in ui if m['role'] == 'user'] == ['hello', 'next'] + assert len(history) == 4 + assert len(meta['log_refs']) == 2 + + +def test_corrupt_zip_is_silent(tmp_path): + path = seed_zip(tmp_path, log_text()) + path.write_bytes(b'not a zip') + assert h.load(tmp_path, record()) is None + + +def test_path_traversal_is_not_a_log_reference(tmp_path): + data = h.metadata(record('../../outside.txt')) + assert data['log_refs'][0]['name'] == '' + assert h.load(tmp_path, data) is None + + +def test_month_boundary_matches_archive(tmp_path): + stamp = dt.datetime(2026, 10, 1, 0, 0).timestamp() + seed_zip(tmp_path, log_text(stamp=stamp), stamp) + assert h.load(tmp_path, record(stamp=stamp - 60)) is not None + + +def test_missing_file_can_be_recovered_by_metadata(tmp_path): + seed_mr(tmp_path, 'model_responses_renamed.txt') + result = h.load(tmp_path, record()) + assert result[2]['log_refs'][0]['name'] == 'model_responses_renamed.txt' + + +def test_large_history_does_not_grow_persisted_metadata(): + a = {'id': 'sess-test', **record(), 'messages': [{'role': 'user', 'content': 'hello'}]} + small = canonical_session_record(a, now=STAMP) + a['messages'].extend({'role': 'assistant', 'content': 'x' * 5000} for _ in range(200)) + a['llm_history'] = [{'role': 'assistant', 'content': 'y' * 100000}] + assert canonical_session_record(a, now=STAMP) == small + assert len(json.dumps(small)) < 1200 + + +def test_archive_then_hot_continuation_chain(tmp_path): + seed_zip(tmp_path, log_text()) + seed_mr(tmp_path, 'model_responses_next.txt', user='next', stamp=STAMP + 3600) + item = record() + item['log_refs'] += record('model_responses_next.txt', 'next', STAMP + 3600)['log_refs'] + ui, history, _ = h.load(tmp_path, item) + assert [m['content'] for m in ui if m['role'] == 'user'] == ['hello', 'next'] + assert len(history) == 4 + + +def test_replay_preserves_native_message_time(): + messages, _ = h.replay(log_text()) + assert [m['ts'] for m in messages] == [STAMP, STAMP]