Files
hermes-webui/tests/test_session_recovery_api.py
nesquena-hermesandfranksong2702 e2ade27bfe fix(anchor): preserve settled mixed content order (#4958)
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>
2026-06-27 22:54:29 +00:00

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