Files
hermes-webui/tests/test_issue2914_truncation_watermark.py
T

732 lines
28 KiB
Python

"""Regression tests for #2914 state.db tail replay after undo/retry/edit."""
from __future__ import annotations
def _msg(role: str, content: str, ts: float, mid: str) -> dict:
return {"id": mid, "role": role, "content": content, "timestamp": ts}
def test_reconciled_messages_skip_state_tail_after_sidecar_truncation():
from api.models import Session, reconciled_state_db_messages_for_session
sidecar = [
_msg("user", "first", 1.0, "sidecar-u1"),
_msg("assistant", "reply first", 2.0, "sidecar-a1"),
]
state_db = [
_msg("user", "first", 1.0, "state-u1"),
_msg("assistant", "reply first", 2.0, "state-a1"),
_msg("user", "second", 3.0, "state-u2"),
_msg("assistant", "reply second", 4.0, "state-a2"),
]
session = Session(
session_id="issue2914",
messages=sidecar,
truncation_watermark=2.0,
)
merged = reconciled_state_db_messages_for_session(session, state_messages=state_db)
assert [m["content"] for m in merged] == ["first", "reply first"]
def test_empty_sidecar_truncation_watermark_blocks_state_replay():
from api.models import Session, reconciled_state_db_messages_for_session
state_db = [
_msg("user", "only prompt", 1.0, "state-u1"),
_msg("assistant", "only reply", 2.0, "state-a1"),
]
session = Session(
session_id="issue2914empty",
messages=[],
truncation_watermark=0.0,
)
assert reconciled_state_db_messages_for_session(session, state_messages=state_db) == []
def test_undo_persists_truncation_watermark_at_new_tail(monkeypatch, tmp_path):
import api.models as models
from api.models import Session
from api.session_ops import undo_last
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
session = Session(
session_id="issue2914undo",
messages=[
_msg("user", "first", 1.0, "u1"),
_msg("assistant", "reply first", 2.0, "a1"),
_msg("user", "second", 3.0, "u2"),
_msg("assistant", "reply second", 4.0, "a2"),
],
)
session.save()
undo_last("issue2914undo")
loaded = Session.load("issue2914undo")
assert loaded is not None
assert [m["content"] for m in loaded.messages] == ["first", "reply first"]
assert loaded.truncation_watermark == 2.0
def test_truncate_endpoint_also_truncates_context_messages(monkeypatch, tmp_path):
"""POST /api/session/truncate must truncate context_messages in sync with
messages so the agent's model-facing context doesn't retain rows the user
removed via Edit / Regenerate (#2914).
Integration test: calls the real handle_post route, not a simulation.
"""
import json
from io import BytesIO
from types import SimpleNamespace
import api.models as models
import api.routes as routes
from api.models import Session
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
session = Session(
session_id="issue2914truncate",
messages=[
_msg("user", "first", 1.0, "u1"),
_msg("assistant", "reply first", 2.0, "a1"),
_msg("user", "second", 3.0, "u2"),
_msg("assistant", "reply second", 4.0, "a2"),
],
context_messages=[
_msg("user", "first", 1.0, "u1"),
_msg("assistant", "reply first", 2.0, "a1"),
_msg("user", "second", 3.0, "u2"),
_msg("assistant", "reply second", 4.0, "a2"),
],
)
session.save()
# Call the real truncate endpoint via handle_post
body = {"session_id": "issue2914truncate", "keep_count": 2}
body_bytes = json.dumps(body).encode()
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
captured_response = {}
def fake_j(handler, payload, status=200, extra_headers=None):
captured_response["payload"] = payload
monkeypatch.setattr(routes, "j", fake_j)
handler = SimpleNamespace(
headers={"Content-Length": str(len(body_bytes))},
rfile=BytesIO(body_bytes),
)
routes.handle_post(handler, SimpleNamespace(path="/api/session/truncate"))
assert captured_response["payload"].get("ok") is True
loaded = Session.load("issue2914truncate")
assert loaded is not None
assert [m["content"] for m in loaded.messages] == ["first", "reply first"]
assert [m["content"] for m in loaded.context_messages] == ["first", "reply first"]
assert loaded.truncation_watermark == 2.0
def test_truncate_endpoint_compaction_leading_context_row(monkeypatch, tmp_path):
"""Context longer than display (leading compaction row): naive [:keep] would
leave a stale tail; truncate must align suffix to display prefix (#5096 / C).
"""
import json
from io import BytesIO
from types import SimpleNamespace
import api.models as models
import api.routes as routes
from api.models import Session
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
monkeypatch.setattr(
"api.config._evict_session_agent",
lambda _sid: None,
)
display = [
_msg("user", "u1", 1.0, "u1"),
_msg("assistant", "a1", 2.0, "a1"),
_msg("user", "REMOVE", 3.0, "u2"),
]
context = [
_msg("user", "compaction-only", 0.5, "cref"),
_msg("user", "u1", 1.0, "cu1"),
_msg("assistant", "a1", 2.0, "ca1"),
_msg("user", "REMOVE", 3.0, "cu2"),
]
session = Session(
session_id="issue5096truncatectx",
messages=display,
context_messages=context,
)
session.save()
body = {"session_id": "issue5096truncatectx", "keep_count": 2}
body_bytes = json.dumps(body).encode()
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
captured_response = {}
def fake_j(handler, payload, status=200, extra_headers=None):
captured_response["payload"] = payload
monkeypatch.setattr(routes, "j", fake_j)
handler = SimpleNamespace(
headers={"Content-Length": str(len(body_bytes))},
rfile=BytesIO(body_bytes),
)
routes.handle_post(handler, SimpleNamespace(path="/api/session/truncate"))
assert captured_response["payload"].get("ok") is True
loaded = Session.load("issue5096truncatectx")
assert loaded is not None
assert [m["content"] for m in loaded.messages] == ["u1", "a1"]
assert len(loaded.context_messages) == 3
assert loaded.context_messages[0]["content"] == "compaction-only"
assert "REMOVE" not in [m["content"] for m in loaded.context_messages]
def test_truncate_without_context_messages_truncation_leaks_to_agent(monkeypatch, tmp_path):
"""Prove the bug: if context_messages is NOT truncated, agent sees old rows."""
import api.models as models
from api.models import Session
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
session = Session(
session_id="issue2914leak",
messages=[
_msg("user", "first", 1.0, "u1"),
_msg("assistant", "reply first", 2.0, "a1"),
_msg("user", "second", 3.0, "u2"),
_msg("assistant", "reply second", 4.0, "a2"),
],
context_messages=[
_msg("user", "first", 1.0, "u1"),
_msg("assistant", "reply first", 2.0, "a1"),
_msg("user", "second", 3.0, "u2"),
_msg("assistant", "reply second", 4.0, "a2"),
],
)
session.save()
# BUGGY path: truncate messages but NOT context_messages
from api.config import _get_session_agent_lock
with _get_session_agent_lock("issue2914leak"):
keep = 2
session.messages = session.messages[:keep]
# Intentionally NOT truncating context_messages (the old buggy behavior)
try:
from api.session_ops import _truncation_watermark_for
session.truncation_watermark = _truncation_watermark_for(session.messages)
except Exception:
session.truncation_watermark = 0.0
session.save()
loaded = Session.load("issue2914leak")
# messages is truncated correctly
assert [m["content"] for m in loaded.messages] == ["first", "reply first"]
# But context_messages still has all 4 — agent will see "second" and "reply second"
assert [m["content"] for m in loaded.context_messages] == [
"first", "reply first", "second", "reply second"
]
def test_edit_then_new_turn_then_undo_leaks_original_via_state_db(monkeypatch, tmp_path):
"""Reproduce #2914: Edit changes message content but original stays in state.db.
Scenario:
1. Send "triangle" (ts=100)
2. Edit to "square" (ts=200, watermark=200)
3. Send "light speed" (ts=300)
4. Undo (removes "light speed", watermark=200)
5. Send "list all" — agent sees original "triangle" from state.db
Root cause: watermark filters m_ts > watermark, but original "triangle"
has ts=100 < watermark=200, so it passes through.
"""
import api.models as models
from api.models import Session, reconciled_state_db_messages_for_session
from api.session_ops import undo_last
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
# Step 1: Initial message "triangle"
session = Session(
session_id="edit_undo_leak",
messages=[
_msg("user", "triangle", 100.0, "u1"),
_msg("assistant", "180°", 101.0, "a1"),
],
context_messages=[
_msg("user", "triangle", 100.0, "u1"),
_msg("assistant", "180°", 101.0, "a1"),
],
)
session.save()
# state.db has the original message
state_db = [
_msg("user", "triangle", 100.0, "state-u1"),
_msg("assistant", "180°", 101.0, "state-a1"),
]
# Step 2: Edit "triangle" → "square" (new timestamp 200)
# Truncate endpoint: keep_count=0, then new message appended
from api.config import _get_session_agent_lock
with _get_session_agent_lock("edit_undo_leak"):
session.messages = []
session.context_messages = []
session.truncation_watermark = 0.0
session.save()
# New message "square" with new timestamp
session.messages = [
_msg("user", "square", 200.0, "u2"),
_msg("assistant", "360°", 201.0, "a2"),
]
session.context_messages = [
_msg("user", "square", 200.0, "u2"),
_msg("assistant", "360°", 201.0, "a2"),
]
session.truncation_watermark = 200.0
session.save()
# state.db now has both original and edited
state_db = [
_msg("user", "triangle", 100.0, "state-u1"),
_msg("assistant", "180°", 101.0, "state-a1"),
_msg("user", "square", 200.0, "state-u2"),
_msg("assistant", "360°", 201.0, "state-a2"),
]
# Step 3: New message "light speed"
session.messages = [
_msg("user", "square", 200.0, "u2"),
_msg("assistant", "360°", 201.0, "a2"),
_msg("user", "light speed", 300.0, "u3"),
_msg("assistant", "300000 km/s", 301.0, "a3"),
]
session.context_messages = [
_msg("user", "square", 200.0, "u2"),
_msg("assistant", "360°", 201.0, "a2"),
_msg("user", "light speed", 300.0, "u3"),
_msg("assistant", "300000 km/s", 301.0, "a3"),
]
session.truncation_watermark = 301.0
session.save()
# state.db has everything
state_db = [
_msg("user", "triangle", 100.0, "state-u1"),
_msg("assistant", "180°", 101.0, "state-a1"),
_msg("user", "square", 200.0, "state-u2"),
_msg("assistant", "360°", 201.0, "state-a2"),
_msg("user", "light speed", 300.0, "state-u3"),
_msg("assistant", "300000 km/s", 301.0, "state-a3"),
]
# Step 4: Undo — removes "light speed"
undo_last("edit_undo_leak")
loaded = Session.load("edit_undo_leak")
# After undo: messages should be ["square", "360°"]
assert [m["content"] for m in loaded.messages] == ["square", "360°"]
assert [m["content"] for m in loaded.context_messages] == ["square", "360°"]
assert loaded.truncation_watermark == 201.0
# Step 5: New turn — agent context should NOT include "triangle"
merged = reconciled_state_db_messages_for_session(
loaded, state_messages=state_db, prefer_context=True,
)
contents = [m["content"] for m in merged]
# "triangle" must NOT leak through — it was replaced by "square" via Edit
assert "triangle" not in contents, \
f"Original 'triangle' leaked through watermark filter! Contents: {contents}"
assert "square" in contents
# "light speed" properly filtered by watermark
assert "light speed" not in contents
# ── save() watermark invariant ─────────────────────────────────────────────
def test_save_does_not_auto_clear_truncation_watermark(monkeypatch, tmp_path):
"""save() must NOT auto-clear truncation_watermark when messages have
timestamps beyond the watermark (#2914).
A future save() that appends newer messages must NOT silently remove the
watermark — that would let state.db replay the rows the user deliberately
removed.
"""
import api.models as models
from api.models import Session
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
session = Session(
session_id="watermark_no_clear",
messages=[
_msg("user", "first", 1.0, "u1"),
_msg("assistant", "reply", 2.0, "a1"),
_msg("user", "newer", 3.0, "u2"), # ts > watermark
],
truncation_watermark=2.0,
)
session.save()
loaded = Session.load("watermark_no_clear")
assert loaded is not None
# Watermark must NOT be cleared even though max message ts (3.0) > watermark (2.0)
assert loaded.truncation_watermark == 2.0, \
f"Watermark was cleared on save! Got {loaded.truncation_watermark}"
# ── Streaming finalize: watermark must not become permanent ceiling ──
def test_streaming_finalize_preserves_new_turns_after_edit(monkeypatch, tmp_path):
"""Integration: simulate the full streaming finalize pipeline for two
consecutive turns after Edit, verifying that context_messages retains
all new turns (not just the pre-Edit history).
This exercises the actual finalize chain:
_restore_reasoning_metadata → _dedupe_replayed_context_messages
→ _deduplicate_context_messages
Scenario:
1. Edit sets watermark = 101.0, context_messages = [pre-edit messages]
2. Turn 1: agent returns [pre-edit + Turn 1] → finalize → save context_messages
3. Turn 2: agent returns [pre-edit + Turn 1 + Turn 2] → finalize → save
4. Assert context_messages after Turn 2 contains Turn 1 + Turn 2
"""
import api.models as models
from api.models import Session
from api.streaming import (
_restore_reasoning_metadata,
_dedupe_replayed_context_messages,
_deduplicate_context_messages,
)
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
# Pre-Edit context (after Edit, watermark = 101.0)
pre_edit = [
_msg("user", "square", 100.0, "ctx-u1"),
_msg("assistant", "360°", 101.0, "ctx-a1"),
]
session = Session(
session_id="finalize_two_turns",
messages=list(pre_edit),
context_messages=list(pre_edit),
truncation_watermark=101.0,
)
session.save()
models.SESSIONS["finalize_two_turns"] = session
# ── Turn 1: agent returns [pre-edit + Turn 1] ──
_previous_context = list(session.context_messages)
_result_messages_turn1 = [
_msg("user", "square", 100.0, "ctx-u1"),
_msg("assistant", "360°", 101.0, "ctx-a1"),
_msg("user", "what is circle", 200.0, "ctx-u2"),
_msg("assistant", "360 degrees", 201.0, "ctx-a2"),
]
# Run the finalize chain
_next = _restore_reasoning_metadata(_previous_context, _result_messages_turn1)
_next = _dedupe_replayed_context_messages(_previous_context, _next)
session.context_messages = _deduplicate_context_messages(_next)
# Turn 1: context must contain the new turn
turn1_contents = [m["content"] for m in session.context_messages]
assert "what is circle" in turn1_contents, \
f"Turn 1 lost after finalize! Contents: {turn1_contents}"
assert "360 degrees" in turn1_contents, \
f"Turn 1 reply lost after finalize! Contents: {turn1_contents}"
# ── Turn 2: agent returns [pre-edit + Turn 1 + Turn 2] ──
_previous_context = list(session.context_messages)
_result_messages_turn2 = [
_msg("user", "square", 100.0, "ctx-u1"),
_msg("assistant", "360°", 101.0, "ctx-a1"),
_msg("user", "what is circle", 200.0, "ctx-u2"),
_msg("assistant", "360 degrees", 201.0, "ctx-a2"),
_msg("user", "list all shapes", 300.0, "ctx-u3"),
_msg("assistant", "square, circle", 301.0, "ctx-a3"),
]
# Run the finalize chain again
_next = _restore_reasoning_metadata(_previous_context, _result_messages_turn2)
_next = _dedupe_replayed_context_messages(_previous_context, _next)
session.context_messages = _deduplicate_context_messages(_next)
# Turn 2: context must contain BOTH Turn 1 and Turn 2
turn2_contents = [m["content"] for m in session.context_messages]
assert "what is circle" in turn2_contents, \
f"Turn 1 lost after Turn 2 finalize! Contents: {turn2_contents}"
assert "360 degrees" in turn2_contents, \
f"Turn 1 reply lost after Turn 2 finalize! Contents: {turn2_contents}"
assert "list all shapes" in turn2_contents, \
f"Turn 2 lost after finalize! Contents: {turn2_contents}"
assert "square, circle" in turn2_contents, \
f"Turn 2 reply lost after finalize! Contents: {turn2_contents}"
def test_streaming_finalize_does_not_leak_original_after_edit(monkeypatch, tmp_path):
"""After Edit, the original replaced message must NOT appear in context_messages
after streaming finalize (#2914).
This exercises the actual finalize chain:
_restore_reasoning_metadata → _dedupe_replayed_context_messages
→ _deduplicate_context_messages
Scenario:
1. Edit sets watermark = 101.0, context_messages = [pre-edit messages]
2. Turn 1: agent returns [pre-edit + Turn 1] → finalize
3. Turn 2: agent returns [pre-edit + Turn 1 + Turn 2] → finalize
4. Assert context_messages after Turn 2 contains Turn 1 + Turn 2
"""
import api.models as models
from api.models import Session
from api.streaming import (
_restore_reasoning_metadata,
_dedupe_replayed_context_messages,
_deduplicate_context_messages,
)
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
# Pre-Edit context (after Edit, watermark = 101.0)
pre_edit = [
_msg("user", "square", 100.0, "ctx-u1"),
_msg("assistant", "360°", 101.0, "ctx-a1"),
]
session = Session(
session_id="finalize_two_turns",
messages=list(pre_edit),
context_messages=list(pre_edit),
truncation_watermark=101.0,
)
session.save()
models.SESSIONS["finalize_two_turns"] = session
# ── Turn 1: agent returns [pre-edit + Turn 1] ──
_previous_context = list(session.context_messages)
_result_messages_turn1 = [
_msg("user", "square", 100.0, "ctx-u1"),
_msg("assistant", "360°", 101.0, "ctx-a1"),
_msg("user", "what is circle", 200.0, "ctx-u2"),
_msg("assistant", "360 degrees", 201.0, "ctx-a2"),
]
# Run the finalize chain
_next = _restore_reasoning_metadata(_previous_context, _result_messages_turn1)
_next = _dedupe_replayed_context_messages(_previous_context, _next)
session.context_messages = _deduplicate_context_messages(_next)
# Turn 1: context must contain the new turn
turn1_contents = [m["content"] for m in session.context_messages]
assert "what is circle" in turn1_contents, \
f"Turn 1 lost after finalize! Contents: {turn1_contents}"
assert "360 degrees" in turn1_contents, \
f"Turn 1 reply lost after finalize! Contents: {turn1_contents}"
# ── Turn 2: agent returns [pre-edit + Turn 1 + Turn 2] ──
_previous_context = list(session.context_messages)
_result_messages_turn2 = [
_msg("user", "square", 100.0, "ctx-u1"),
_msg("assistant", "360°", 101.0, "ctx-a1"),
_msg("user", "what is circle", 200.0, "ctx-u2"),
_msg("assistant", "360 degrees", 201.0, "ctx-a2"),
_msg("user", "list all shapes", 300.0, "ctx-u3"),
_msg("assistant", "square, circle", 301.0, "ctx-a3"),
]
# Run the finalize chain again
_next = _restore_reasoning_metadata(_previous_context, _result_messages_turn2)
_next = _dedupe_replayed_context_messages(_previous_context, _next)
session.context_messages = _deduplicate_context_messages(_next)
# Turn 2: context must contain BOTH Turn 1 and Turn 2
turn2_contents = [m["content"] for m in session.context_messages]
assert "what is circle" in turn2_contents, \
f"Turn 1 lost after Turn 2 finalize! Contents: {turn2_contents}"
assert "360 degrees" in turn2_contents, \
f"Turn 1 reply lost after Turn 2 finalize! Contents: {turn2_contents}"
assert "list all shapes" in turn2_contents, \
f"Turn 2 lost after finalize! Contents: {turn2_contents}"
assert "square, circle" in turn2_contents, \
f"Turn 2 reply lost after finalize! Contents: {turn2_contents}"
def test_edit_does_not_leak_original_message_into_context_via_reconcile(monkeypatch, tmp_path):
"""After Edit, the original replaced message must NOT appear in agent context
when reconciling state.db with sidecar (#2914).
This is the core regression: Edit replaces message content but state.db
keeps the original forever. Without the sub-watermark filter in
merge_session_messages_append_only, the original message leaks back
into context_messages on the next turn.
Scenario:
1. Send "triangle" (ts=100) → agent replies "180°" (ts=101)
2. Edit to "square" (ts=200) → agent replies "360°" (ts=201), watermark=200
3. Send "list all" (ts=300) → agent replies "square, list" (ts=301)
4. Reconcile state.db (which has both "triangle" and "square") with sidecar
5. Assert "triangle" does NOT appear in merged context
"""
import api.models as models
from api.models import Session, reconciled_state_db_messages_for_session
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
# After Edit + new turn, sidecar has:
# - "square" (edited, ts=200)
# - "360°" (ts=201)
# - "list all" (ts=300)
# - "square, list" (ts=301)
# Watermark = 301.0 (covers everything in sidecar)
sidecar = [
_msg("user", "square", 200.0, "side-u1"),
_msg("assistant", "360°", 201.0, "side-a1"),
_msg("user", "list all", 300.0, "side-u2"),
_msg("assistant", "square, list", 301.0, "side-a2"),
]
session = Session(
session_id="edit_no_leak",
messages=list(sidecar),
context_messages=list(sidecar),
truncation_watermark=301.0,
)
session.save()
# state.db has BOTH original "triangle" AND edited "square"
state_db = [
_msg("user", "triangle", 100.0, "state-u1"), # original, ts < watermark
_msg("assistant", "180°", 101.0, "state-a1"), # original, ts < watermark
_msg("user", "square", 200.0, "state-u2"), # edited, ts <= watermark
_msg("assistant", "360°", 201.0, "state-a2"), # edited, ts <= watermark
_msg("user", "list all", 300.0, "state-u3"), # new turn
_msg("assistant", "square, list", 301.0, "state-a4"),
]
merged = reconciled_state_db_messages_for_session(
session, state_messages=state_db, prefer_context=True,
)
contents = [m["content"] for m in merged]
# "triangle" must NOT leak — it was replaced by "square" via Edit
assert "triangle" not in contents, \
f"Original 'triangle' leaked into context! Contents: {contents}"
# "square" must be present
assert "square" in contents
# All sidecar messages must be present
assert "360°" in contents
assert "list all" in contents
assert "square, list" in contents
def test_above_watermark_state_row_merges_once_sidecar_advances():
"""A genuine future state.db-only row must merge after the session advances.
Because Session.save() no longer auto-clears the truncation_watermark, an
unconditional `timestamp > watermark` skip in merge_session_messages_append_only
would become a PERMANENT ceiling: once the user edits/undoes (setting the
watermark) and then keeps chatting, a later turn that lands in state.db but
is missed by the sidecar (recovery/compaction) would be silently dropped from
/api/session and model-context reconstruction forever. The fix only applies the
above-watermark skip while the sidecar has NOT advanced past the watermark.
Codex regression-gate finding on #3102 (v0.51.197).
"""
from api.models import merge_session_messages_append_only as merge
# Session edited at watermark=2.0, then advanced: sidecar tail is now 4.1.
sidecar = [
_msg("user", "q1", 1.0, "s-u1"),
_msg("assistant", "a1", 2.0, "s-a1"),
_msg("user", "q2 after edit", 4.0, "s-u2"),
_msg("assistant", "a2 after edit", 4.1, "s-a2"),
]
# state.db carries a genuine NEWER row (5.0) the sidecar hasn't observed yet.
state = sidecar + [_msg("assistant", "future recovery row", 5.0, "st-recov")]
merged = merge(sidecar, state, truncation_watermark=2.0)
contents = [m["content"] for m in merged]
assert "future recovery row" in contents, (
"After the session advanced past the watermark, a future state.db-only "
f"recovery row must merge, not be permanently dropped. Got: {contents}"
)
def test_above_watermark_deleted_tail_still_filtered_when_sidecar_not_advanced():
"""The #2914 deleted-tail filter must still hold when the sidecar has NOT advanced.
Counterpart to the test above: if the user edited/truncated (watermark=2.0)
and the sidecar tail is still at/below the watermark, a stale deleted-tail row
in state.db ABOVE the watermark must NOT reappear.
"""
from api.models import merge_session_messages_append_only as merge
sidecar = [
_msg("user", "q1", 1.0, "s-u1"),
_msg("assistant", "a1", 2.0, "s-a1"),
]
state = sidecar + [_msg("assistant", "deleted tail", 5.0, "st-deleted")]
merged = merge(sidecar, state, truncation_watermark=2.0)
contents = [m["content"] for m in merged]
assert "deleted tail" not in contents, (
"A stale deleted-tail row above the watermark must stay filtered while "
f"the sidecar has not advanced past the watermark. Got: {contents}"
)