Store desktop session metadata only and replay MR or L4 history
This commit is contained in:
1 parent
2ba5529060
commit
a2eb5414c2
7 files changed
+433
-68
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+52
-55
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user