When a settled assistant turn carries _anchor_activity_scene and has mixed content[] (text interleaved with tool_use), promote that ordering into anchor scene rows (prose / tool / thinking rows in sequence) instead of only the raw content[] fallback, and mirror the same scene model in the backend hydration path so a cold reload reconstructs an identical transcript. Also fixes: post-tool_use text/thinking in a NON-FINAL assistant message was silently dropped — only the turn-final assistant's post-last-tool text is the "final answer" tail; earlier assistant messages keep post-tool content as activity rows. Tail thinking is excluded from the final-answer text but still emitted as a thinking row (verified on both JS and Python paths). Tool-row de-duplication is conservative — ID-equal always merges; name/ invocation matches are cardinality-gated and same-message; body-prefix is only a secondary confirmation inside the id-flexible branch (also requires matching started_at + compatible name + invocation). It biases toward an extra visible tool card over ever silently merging two distinct calls. Gate: Codex SAFE (tail-thinking edge verified already-handled both sides), Opus SHIP (dedup fails toward duplicate-not-loss; non-final preservation, XSS, non-mixed regression all clean), full suite 10778 passed (only the 4 known pre-existing flakes). One non-blocking follow-up noted: bring the prose/thinking peer-dedup into JS↔Python parity (cosmetic cold-reload extra-narration row). Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
87 lines
2.9 KiB
Python
87 lines
2.9 KiB
Python
import json
|
|
|
|
from api.session_recovery import audit_session_recovery, repair_safe_session_recovery
|
|
|
|
|
|
def _write_session(session_dir, sid, messages=1):
|
|
path = session_dir / f"{sid}.json"
|
|
path.write_text(
|
|
json.dumps({"id": sid, "session_id": sid, "title": sid, "messages": [{"role": "user", "content": str(i)} for i in range(messages)]}),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
def test_repair_safe_session_recovery_restores_backup_and_rebuilds_index(tmp_path, monkeypatch):
|
|
import api.models as _m
|
|
|
|
sid = "abc123"
|
|
live = _write_session(tmp_path, sid, messages=4)
|
|
bak = tmp_path / f"{sid}.json.bak"
|
|
bak.write_text(live.read_text(encoding="utf-8"), encoding="utf-8")
|
|
live.unlink()
|
|
index = tmp_path / "_index.json"
|
|
index.write_text(json.dumps([]), encoding="utf-8")
|
|
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
|
monkeypatch.setattr(_m, "SESSION_INDEX_FILE", index)
|
|
stale = _m.Session(
|
|
session_id="stale_cached",
|
|
title="stale",
|
|
messages=[{"role": "user", "content": "from another test"}],
|
|
)
|
|
_m.SESSIONS[stale.session_id] = stale
|
|
|
|
try:
|
|
result = repair_safe_session_recovery(tmp_path)
|
|
finally:
|
|
_m.SESSIONS.pop(stale.session_id, None)
|
|
|
|
assert result["clean"] is True
|
|
assert result["ok"] is True
|
|
assert result["repaired"] == 1
|
|
assert live.exists()
|
|
assert audit_session_recovery(tmp_path)["status"] == "ok"
|
|
idx = json.loads(index.read_text(encoding="utf-8"))
|
|
assert [entry["session_id"] for entry in idx] == [sid]
|
|
|
|
|
|
def test_repair_safe_session_recovery_leaves_unsafe_orphan_for_manual_review(tmp_path):
|
|
import sqlite3
|
|
|
|
sid = "abc123"
|
|
live = _write_session(tmp_path, sid, messages=1)
|
|
bak = tmp_path / f"{sid}.json.bak"
|
|
bak.write_text(live.read_text(encoding="utf-8"), encoding="utf-8")
|
|
live.unlink()
|
|
db = tmp_path / "state.db"
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute("create table sessions (id text primary key)")
|
|
conn.execute("insert into sessions (id) values (?)", ("other",))
|
|
|
|
result = repair_safe_session_recovery(tmp_path, state_db_path=db)
|
|
|
|
assert result["clean"] is False
|
|
assert result["ok"] is False
|
|
assert result["repaired"] == 0
|
|
assert not live.exists()
|
|
assert result["after"]["status"] == "needs_manual_review"
|
|
|
|
|
|
def test_repair_safe_route_uses_clean_flag_for_status_code():
|
|
from pathlib import Path
|
|
|
|
src = Path("api/routes.py").read_text(encoding="utf-8")
|
|
|
|
assert 'status=200 if result.get("clean") else 409' in src
|
|
|
|
|
|
def test_recovery_audit_routes_are_registered():
|
|
from pathlib import Path
|
|
|
|
src = Path("api/routes.py").read_text(encoding="utf-8")
|
|
|
|
assert 'parsed.path == "/api/session/recovery/audit"' in src
|
|
assert 'parsed.path == "/api/session/recovery/repair-safe"' in src
|
|
assert "audit_session_recovery" in src
|
|
assert "repair_safe_session_recovery" in src
|