fix(#4767): advance truncation_watermark instead of clearing to None after edit/retry/undo (#2914, #3831)

This commit is contained in:
AlexeyDsov authored and nesquena-hermes committed 2026-06-24 18:08:50 +00:00
1 parent 3917400c80
commit e1bbf3e807
5 files changed
+498 -75

No files matched your search

+8 -5
View File
@@ -428,12 +428,15 @@ def _append_recovered_pending_turn(session, *, timestamp: int | None = None) ->
recovered['attachments'] = list(session.pending_attachments)
session.messages.append(recovered)
_append_recovered_turn_to_context(session, recovered)
# The new user turn is now committed to messages (#3831): retire a positive
# truncation watermark from a prior retry/undo/edit so it can't freeze at the
# old edit boundary and drop these post-edit turns on a later empty-sidecar
# reconcile. None, never 0.0 (the truncate-to-empty sentinel, #2914).
# The new user turn is now committed to messages (#3831): advance the
# truncation watermark to the new message's timestamp so that
# merge_session_messages_append_only() still filters out replaced
# pre-edit rows from state.db whose timestamps fall below the boundary.
# The merge's sidecar_advanced_past_watermark guard allows state.db rows
# newer than the watermark, so post-edit turns are not dropped.
# Never 0.0 (the truncate-to-empty sentinel, #2914).
if getattr(session, 'truncation_watermark', None):
session.truncation_watermark = None
session.truncation_watermark = recovered_ts
return recovered
+8 -8
View File
@@ -16243,15 +16243,15 @@ def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, st
if attachments:
user_msg["attachments"] = list(attachments)
s.messages.append(user_msg)
# The new user turn is now committed to messages (#3831): a positive
# truncation watermark from a prior retry/undo/edit has been superseded and
# must retire, else it freezes at the old edit boundary and later drops these
# post-edit turns on an empty-sidecar reconcile. Safe here (not at chat-start)
# because the row is durably in messages, so the merge's max-sidecar guard now
# suppresses the replaced tail without the watermark. Cleared to None — never
# 0.0, which is the truncate-to-empty sentinel (#2914).
# The new user turn is now committed to messages (#3831): advance the
# truncation watermark to the new message's timestamp so that
# merge_session_messages_append_only() still filters out replaced
# pre-edit rows from state.db whose timestamps fall below the boundary.
# The merge's sidecar_advanced_past_watermark guard (models.py:5172)
# allows state.db rows newer than the watermark, so post-edit turns
# are not dropped. Never 0.0 (the truncate-to-empty sentinel, #2914).
if getattr(s, "truncation_watermark", None):
s.truncation_watermark = None
s.truncation_watermark = user_msg.get("timestamp") or time.time()
def _is_default_or_empty_session_title(title) -> bool:
+29 -20
View File
@@ -4505,21 +4505,30 @@ def _stream_writeback_can_supersede_recovery_marker(session, msg_text):
return False
def _retire_truncation_watermark_after_commit(session) -> None:
"""Clear a positive truncation watermark once a new user turn is committed
def _advance_truncation_watermark_after_commit(session) -> None:
"""Advance a positive truncation watermark once a new user turn is committed
to ``session.messages`` (#3831).
retry/undo/Edit set a positive watermark to suppress the *replaced* tail from
the append-only state.db merge; Session.save() deliberately never auto-clears
it (#2914). But nothing retired it when the user then sent a NEW turn, so it
froze at the old edit boundary and later dropped those post-edit turns on an
empty-sidecar reconcile. Once the new turn is durably in messages the merge's
max-sidecar guard suppresses the replaced tail without the watermark, so it is
safe — and necessary — to retire it here. Cleared to None, never 0.0 (the
truncate-to-empty sentinel that must keep blocking replay, #2914).
it (#2914). Once the new turn is durably in messages we advance the watermark
to the newest user message timestamp so that state.db rows newer than the
watermark are still merged in, while the replaced pre-edit tail remains
filtered. Never 0.0 (the truncate-to-empty sentinel that must keep blocking
replay, #2914).
"""
if getattr(session, 'truncation_watermark', None):
session.truncation_watermark = None
if not getattr(session, 'truncation_watermark', None):
return
messages = getattr(session, 'messages', None) or []
# Walk backwards to find the newest user message timestamp
for msg in reversed(messages):
if isinstance(msg, dict) and msg.get('role') == 'user':
ts = msg.get('timestamp')
if isinstance(ts, (int, float)) and ts > 0:
session.truncation_watermark = float(ts)
return
import time as _time
session.truncation_watermark = _time.time()
def _merge_display_messages_after_agent_result(previous_display, previous_context, result_messages, msg_text, source: str = "webui"):
@@ -5183,14 +5192,14 @@ def _materialize_pending_user_turn_before_error(session) -> bool:
for e in ctx[-8:]
):
ctx.append({k: v for k, v in recovered.items() if k != 'timestamp'})
# The new user turn is now committed to messages (#3831): retire a positive
# truncation watermark left over from a prior retry/undo/edit so it cannot
# freeze at the old edit boundary and later drop these post-edit turns on an
# empty-sidecar reconcile. Cleared to None — never 0.0 (the truncate-to-empty
# sentinel, #2914). Safe here because the row is durably in messages, so the
# merge's max-sidecar guard suppresses the replaced tail without the watermark.
# The new user turn is now committed to messages (#3831): advance a positive
# truncation watermark left over from a prior retry/undo/edit so that
# merge_session_messages_append_only() still filters out replaced pre-edit
# rows from state.db. The merge's sidecar_advanced_past_watermark guard
# allows state.db rows newer than the watermark, so post-edit turns are not
# dropped. Never 0.0 (the truncate-to-empty sentinel, #2914).
if getattr(session, 'truncation_watermark', None):
session.truncation_watermark = None
session.truncation_watermark = float(recovered_ts)
return True
@@ -7538,7 +7547,7 @@ def _run_agent_streaming(
msg_text,
source=getattr(s, 'pending_user_source', None) or 'webui',
)
_retire_truncation_watermark_after_commit(s) # #3831
_advance_truncation_watermark_after_commit(s) # #3831
# Strip XML tool-call blocks from assistant message content.
# DeepSeek and some other providers emit <function_calls>...</function_calls>
# in the raw response text; this must be removed before the content is
@@ -7831,7 +7840,7 @@ def _run_agent_streaming(
msg_text,
source=getattr(s, 'pending_user_source', None) or 'webui',
)
_retire_truncation_watermark_after_commit(s) # #3831
_advance_truncation_watermark_after_commit(s) # #3831
# Skip the error block — jump directly to the
# normal post-result persistence path by
# leaving _assistant_added truthy (set below).
@@ -8862,7 +8871,7 @@ def _run_agent_streaming(
msg_text,
source=getattr(s, 'pending_user_source', None) or 'webui',
)
_retire_truncation_watermark_after_commit(s) # #3831
_advance_truncation_watermark_after_commit(s) # #3831
s.save()
logger.info('[webui] self-heal (except path): retry succeeded')
return # skip error emission
+105 -42
View File
@@ -8,18 +8,22 @@ it froze at the old edit boundary. A frozen watermark then dropped post-watermar
state.db rows whenever the sidecar was later reconstructed empty
(recovery/reconcile), permanently losing the turns sent after the edit.
Fix: retire a *positive* watermark to ``None`` once the new user turn is
COMMITTED to ``session.messages`` (the success-merge, eager-checkpoint, and
recovery/cold-load commit points) — NOT at chat-start. Clearing at chat-start
was unsafe: in deferred mode (the default) the new user row isn't in messages
yet, so a merge in that window would resurrect the replaced tail (the
max-sidecar guard hasn't risen past the old boundary). Once the row is committed,
``max_sidecar_timestamp`` rises past the replaced tail and the merge suppresses
it without the watermark, so retiring it is both safe and necessary.
Fix: advance a *positive* watermark to the newest user message timestamp once
the new user turn is COMMITTED to ``session.messages`` (the success-merge,
eager-checkpoint, and recovery/cold-load commit points) — NOT at chat-start.
Clearing at chat-start was unsafe: in deferred mode (the default) the new user
row isn't in messages yet, so a merge in that window would resurrect the replaced
tail (the max-sidecar guard hasn't risen past the old boundary). Once the row is
committed, ``max_sidecar_timestamp`` rises past the replaced tail and the merge
suppresses it without the watermark, so advancing it is both safe and necessary.
``None`` is distinct from ``0.0``: ``0.0`` is the truncate-to-empty sentinel
(#2914) that must keep blocking all state replay, so the clear is falsy-gated and
never touches ``0.0``.
Advancing to the new message timestamp (instead of clearing to None) keeps the
merge's watermark filter active for replaced pre-edit rows from state.db whose
timestamps fall below the boundary, while the sidecar_advanced_past_watermark
guard allows newer state.db rows to merge in.
``0.0`` is the truncate-to-empty sentinel (#2914) that must keep blocking all
state replay, so the advance is falsy-gated and never touches ``0.0``.
"""
import api.models as models
import api.streaming as streaming
@@ -42,56 +46,90 @@ class _FakeSession:
self.pending_started_at = None
# --- The fix: a committed new turn retires a stale positive watermark ---------
# --- The fix: a committed new turn advances a stale positive watermark --------
def test_retire_helper_clears_positive_watermark():
def test_advance_helper_advances_positive_watermark():
"""After a new user turn is committed, the watermark advances to the
newest user message timestamp — NOT cleared to None."""
s = _FakeSession(100.0) # stale, from a prior retry/undo/edit
s.messages = _rows(("user", "new turn", 200))
streaming._retire_truncation_watermark_after_commit(s)
assert s.truncation_watermark is None
streaming._advance_truncation_watermark_after_commit(s)
assert s.truncation_watermark == 200.0
def test_retire_helper_leaves_zero_watermark_untouched():
def test_advance_helper_leaves_zero_watermark_untouched():
"""0.0 is the truncate-to-empty sentinel (#2914), not a stale boundary — the
falsy guard must leave it alone so #2914 replay-blocking is preserved."""
s = _FakeSession(0.0)
s.messages = _rows(("user", "new turn", 200))
streaming._retire_truncation_watermark_after_commit(s)
streaming._advance_truncation_watermark_after_commit(s)
assert s.truncation_watermark == 0.0
def test_retire_helper_noop_when_unset():
def test_advance_helper_noop_when_unset():
s = _FakeSession(None)
streaming._retire_truncation_watermark_after_commit(s)
streaming._advance_truncation_watermark_after_commit(s)
assert s.truncation_watermark is None
def test_recovery_commit_clears_watermark():
def test_advance_helper_uses_current_time_when_no_timestamp():
"""When the newest user message has no timestamp, the helper falls back to
time.time()."""
s = _FakeSession(100.0)
s.messages = _rows(("user", "new turn", None))
streaming._advance_truncation_watermark_after_commit(s)
assert s.truncation_watermark is not None
assert s.truncation_watermark > 100.0 # advanced past the old watermark
def test_advance_helper_picks_newest_user_timestamp():
"""When there are multiple user messages, the helper picks the newest one."""
s = _FakeSession(50.0)
s.messages = _rows(
("user", "first", 100),
("assistant", "reply", 101),
("user", "second", 200),
("assistant", "reply2", 201),
)
streaming._advance_truncation_watermark_after_commit(s)
assert s.truncation_watermark == 200.0
def test_recovery_commit_advances_watermark():
"""The cold-load / recovery commit path (_append_recovered_pending_turn)
retires a stale positive watermark when it materializes the pending turn."""
advances a stale positive watermark when it materializes the pending turn."""
s = _FakeSession(100.0)
s.pending_user_message = "recovered new turn"
s.pending_attachments = None
models._append_recovered_pending_turn(s, timestamp=200)
assert s.truncation_watermark is None
assert s.truncation_watermark == 200
# The pending turn was committed to messages.
assert any(m.get("content") == "recovered new turn" for m in s.messages)
# --- The effect: cleared (None) watermark stops dropping post-edit turns -------
# --- The effect: advanced watermark still merges post-edit turns --------------
def test_cleared_watermark_keeps_post_edit_state_rows_with_empty_sidecar():
"""Once the watermark is cleared (None), an empty-sidecar reconcile keeps the
genuinely-new post-edit turns instead of dropping them (the #3831 outcome)."""
state = _rows(
def test_advanced_watermark_keeps_post_edit_state_rows():
"""With a sidecar whose max timestamp exceeds the watermark (the real
post-edit state after the assistant reply was appended), post-edit state.db
rows are merged in because sidecar_advanced_past_watermark is True."""
sidecar = _rows(
("user", "q1", 50),
("assistant", "a1", 100), # old edit boundary
("user", "q2-new", 200), # sent AFTER the retry/edit
("assistant", "a2-new", 300),
("user", "q2-new", 200), # new message after edit (watermark=200)
("assistant", "a2-new", 250), # assistant reply — pushes sidecar past watermark
)
state = _rows(
("user", "q1", 50),
("assistant", "a1", 100),
("user", "q2-new", 200),
("assistant", "a2-new", 250),
)
merged = models.merge_session_messages_append_only(
[], state, truncation_watermark=None
sidecar, state, truncation_watermark=200.0
)
# max_sidecar_timestamp=250 > watermark=200 → sidecar_advanced_past_watermark=True
# → watermark filter does not block a2-new
assert [m["content"] for m in merged] == ["q1", "a1", "q2-new", "a2-new"]
@@ -100,7 +138,7 @@ def test_cleared_watermark_keeps_post_edit_state_rows_with_empty_sidecar():
def test_active_watermark_still_filters_replaced_tail_empty_sidecar():
"""A still-active (positive) watermark with an empty sidecar still suppresses
rows above the boundary — the replaced/edited tail (#2914). The fix only
changes WHEN the watermark is retired, never the merge semantics."""
changes WHEN the watermark is advanced, never the merge semantics."""
state = _rows(
("user", "q1", 50),
("assistant", "a1", 100),
@@ -126,19 +164,18 @@ def test_zero_watermark_still_blocks_all_replay_empty_sidecar():
assert merged == []
# --- Inline commit-path coverage (greptile): the two sites that inline the -----
# --- falsy-gated clear instead of calling the helper must behave identically. --
# --- Inline commit-path coverage: the two sites that inline the advance --------
def test_error_path_materialize_clears_positive_watermark():
def test_error_path_materialize_advances_positive_watermark():
"""The error/cancel materialization path (_materialize_pending_user_turn_before_error)
inlines the watermark clear. #3831 was triggered on recovery/reconcile after a
crash, so the error path is precisely the failure mode — lock it down: a pending
user turn committed on the error path retires a stale positive watermark."""
inlines the watermark advance. A pending user turn committed on the error
path advances a stale positive watermark to the recovered timestamp."""
s = _FakeSession(100.0) # stale, from a prior retry/undo/edit
s.pending_user_message = "new turn after edit"
appended = streaming._materialize_pending_user_turn_before_error(s)
assert appended is True
assert s.truncation_watermark is None
assert s.truncation_watermark is not None
assert s.truncation_watermark >= 100.0 # advanced past old watermark
assert any(m.get("content") == "new turn after edit" for m in s.messages)
@@ -151,17 +188,18 @@ def test_error_path_materialize_preserves_zero_sentinel():
assert s.truncation_watermark == 0.0
def test_eager_checkpoint_clears_positive_watermark():
def test_eager_checkpoint_advances_positive_watermark():
"""The eager first-turn checkpoint path (_checkpoint_user_message_for_eager_session_save
in routes.py) inlines the same clear. A committed user turn retires a stale
positive watermark; the 0.0 sentinel is preserved."""
in routes.py) inlines the same advance. A committed user turn advances a
stale positive watermark to the new message timestamp; the 0.0 sentinel is
preserved."""
import api.routes as routes
s = _FakeSession(100.0)
routes._checkpoint_user_message_for_eager_session_save(
s, "eager new turn", None, started_at=200.0
)
assert s.truncation_watermark is None
assert s.truncation_watermark == 200.0
assert any(m.get("content") == "eager new turn" for m in s.messages)
s0 = _FakeSession(0.0)
@@ -170,3 +208,28 @@ def test_eager_checkpoint_clears_positive_watermark():
)
assert s0.truncation_watermark == 0.0
# --- Advanced watermark filters pre-edit state.db rows -------------------------
def test_advanced_watermark_filters_pre_edit_state_db_rows():
"""With the watermark advanced to the new message timestamp, pre-edit state.db
rows whose timestamps are below the watermark AND whose content is not in the
sidecar are still filtered out."""
sidecar = _rows(
("user", "square", 200),
("assistant", "360", 201),
)
state_db = _rows(
("user", "triangle", 100), # pre-edit, below watermark, not in sidecar
("assistant", "180", 101), # pre-edit assistant, below watermark
("user", "square", 200), # in sidecar
("assistant", "360", 201), # in sidecar
)
merged = models.merge_session_messages_append_only(
sidecar, state_db, truncation_watermark=200.0
)
contents = [m["content"] for m in merged]
assert "triangle" not in contents
assert "180" not in contents
assert "square" in contents
assert "360" in contents
+348
View File
@@ -0,0 +1,348 @@
"""End-to-end regression tests for the edit/session-switch watermark bug.
Scenario: user edits a message, sends a new turn, switches to another session,
then switches back — the original (pre-edit) message must NOT reappear from
state.db because the truncation_watermark was advanced (not cleared to None)
when the new turn was committed.
This covers the exact reproduction path:
1. Send message A ("triangle")
2. Edit message A to "square" (truncate + re-send)
3. Send message B ("light speed") — watermark advances to B's timestamp
4. Switch session (evict from LRU cache)
5. Switch back (reload from disk, merge sidecar + state.db)
6. Assert: "triangle" is NOT in the merged transcript
"""
from __future__ import annotations
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
def _msg(role: str, content: str, ts: float, mid: str) -> dict:
return {"id": mid, "role": role, "content": content, "timestamp": ts}
def _setup_session_dir(monkeypatch, tmp_path):
"""Configure models to use a temp session directory."""
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
def _post_truncate(monkeypatch, sid, keep_count):
"""Call POST /api/session/truncate via the real route handler."""
import io
class _JSONHandler:
def __init__(self, body_bytes: bytes):
self.status = None
self.response_headers = []
self.rfile = BytesIO(body_bytes)
self.headers = {"Content-Length": str(len(body_bytes))}
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
pass
def payload(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
body_bytes = json.dumps({"session_id": sid, "keep_count": keep_count}).encode()
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
handler = _JSONHandler(body_bytes)
routes.handle_post(handler, SimpleNamespace(path="/api/session/truncate"))
return {"status": handler.status, "payload": handler.payload()}
def _checkpoint_user(s, msg, started_at, monkeypatch):
"""Call the eager checkpoint path (simulates sending a new message)."""
monkeypatch.setattr(routes, "_check_csrf", lambda handler: True)
routes._checkpoint_user_message_for_eager_session_save(
s, msg, None, started_at=started_at
)
def test_edit_then_new_turn_watermark_advances_not_clears(monkeypatch, tmp_path):
"""After edit → new turn, truncation_watermark should advance to the new
message's timestamp (NOT be cleared to None)."""
_setup_session_dir(monkeypatch, tmp_path)
sid = "edit_watermark_advance"
# Real edit scenario: message 1 + reply kept, message 2 edited
session = Session(
session_id=sid,
messages=[
_msg("user", "first msg", 50.0, "u0"),
_msg("assistant", "first reply", 51.0, "a0"),
_msg("user", "triangle", 100.0, "u1"),
_msg("assistant", "180°", 101.0, "a1"),
],
)
session.save()
# Step 1: Truncate (edit) — keep 2 messages (first exchange)
captured = _post_truncate(monkeypatch, sid, 2)
assert captured["status"] == 200
loaded = Session.load(sid)
assert loaded.truncation_watermark == 51.0 # positive watermark (not 0.0)
# Step 2: Send new message "square" — checkpoint advances watermark
_checkpoint_user(loaded, "square", started_at=200.0, monkeypatch=monkeypatch)
loaded.save()
reloaded = Session.load(sid)
# Watermark should be advanced to 200.0, NOT None
assert reloaded.truncation_watermark is not None, (
"truncation_watermark was cleared to None instead of advanced"
)
assert reloaded.truncation_watermark == 200.0
def test_edit_then_new_turn_then_reload_filters_pre_edit_state_db(monkeypatch, tmp_path):
"""After edit → new turn → reload from disk, pre-edit state.db rows must
NOT appear in the merged transcript."""
_setup_session_dir(monkeypatch, tmp_path)
sid = "edit_reload_filter"
# Real edit scenario: first exchange kept, second message edited
session = Session(
session_id=sid,
messages=[
_msg("user", "first msg", 50.0, "u0"),
_msg("assistant", "first reply", 51.0, "a0"),
_msg("user", "triangle", 100.0, "u1"),
_msg("assistant", "180°", 101.0, "a1"),
],
)
session.save()
# Step 1: Truncate (edit) — keep 2 messages (first exchange)
captured = _post_truncate(monkeypatch, sid, 2)
assert captured["status"] == 200
# Step 2: Send new message "square"
loaded = Session.load(sid)
_checkpoint_user(loaded, "square", started_at=200.0, monkeypatch=monkeypatch)
# Simulate assistant reply being appended to sidecar
loaded.messages.append(_msg("assistant", "360°", 201.0, "a2"))
loaded.save()
# Step 3: Simulate state.db containing both original and edited messages
state_db = [
_msg("user", "first msg", 50.0, "state-u0"),
_msg("assistant", "first reply", 51.0, "state-a0"),
_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 4: Reload from disk (simulates session switch → switch back)
# Evict from cache to force disk reload
with models.LOCK:
models.SESSIONS.pop(sid, None)
reloaded = Session.load(sid)
assert reloaded.truncation_watermark is not None
assert reloaded.truncation_watermark == 200.0
# Step 5: Merge sidecar + state.db (what GET /api/session does)
merged = models.merge_session_messages_append_only(
reloaded.messages,
state_db,
truncation_watermark=reloaded.truncation_watermark,
)
contents = [m["content"] for m in merged]
# "triangle" must NOT leak through
assert "triangle" not in contents, (
f"Pre-edit 'triangle' leaked from state.db! Contents: {contents}"
)
assert "180°" not in contents, (
f"Pre-edit assistant reply leaked from state.db! Contents: {contents}"
)
# "square" must be present
assert "square" in contents
assert "360°" in contents
def test_edit_then_two_turns_then_reload_all_visible(monkeypatch, tmp_path):
"""After edit → two new turns → reload, all post-edit turns are visible
and pre-edit rows are filtered."""
_setup_session_dir(monkeypatch, tmp_path)
sid = "edit_two_turns"
# Real edit scenario: first exchange kept, second message edited
session = Session(
session_id=sid,
messages=[
_msg("user", "first msg", 50.0, "u0"),
_msg("assistant", "first reply", 51.0, "a0"),
_msg("user", "original", 100.0, "u1"),
_msg("assistant", "original reply", 101.0, "a1"),
],
)
session.save()
# Step 1: Truncate (edit) — keep 2 messages
captured = _post_truncate(monkeypatch, sid, 2)
assert captured["status"] == 200
# Step 2: Send edited message
loaded = Session.load(sid)
_checkpoint_user(loaded, "edited", started_at=200.0, monkeypatch=monkeypatch)
loaded.save()
# Step 3: Simulate assistant reply
loaded.messages.append(_msg("assistant", "edited reply", 201.0, "a2"))
loaded.save()
# Step 4: Send second new turn
_checkpoint_user(loaded, "second question", started_at=300.0, monkeypatch=monkeypatch)
# Simulate assistant reply to second question
loaded.messages.append(_msg("assistant", "second reply", 301.0, "a3"))
loaded.save()
# Step 5: Simulate state.db with all messages
state_db = [
_msg("user", "first msg", 50.0, "state-u0"),
_msg("assistant", "first reply", 51.0, "state-a0"),
_msg("user", "original", 100.0, "state-u1"),
_msg("assistant", "original reply", 101.0, "state-a1"),
_msg("user", "edited", 200.0, "state-u2"),
_msg("assistant", "edited reply", 201.0, "state-a2"),
_msg("user", "second question", 300.0, "state-u3"),
_msg("assistant", "second reply", 301.0, "state-a3"),
]
# Step 6: Reload from disk
with models.LOCK:
models.SESSIONS.pop(sid, None)
reloaded = Session.load(sid)
assert reloaded.truncation_watermark is not None
assert reloaded.truncation_watermark == 300.0
# Step 7: Merge
merged = models.merge_session_messages_append_only(
reloaded.messages,
state_db,
truncation_watermark=reloaded.truncation_watermark,
)
contents = [m["content"] for m in merged]
# Pre-edit rows filtered
assert "original" not in contents
assert "original reply" not in contents
# Post-edit rows present
assert "edited" in contents
assert "edited reply" in contents
assert "second question" in contents
assert "second reply" in contents
def test_retry_then_new_turn_watermark_advances(monkeypatch, tmp_path):
"""After /retry → new turn, watermark should advance (not clear to None)."""
_setup_session_dir(monkeypatch, tmp_path)
from api.session_ops import retry_last
sid = "retry_watermark_advance"
session = Session(
session_id=sid,
messages=[
_msg("user", "first", 100.0, "u1"),
_msg("assistant", "reply", 101.0, "a1"),
_msg("user", "second", 200.0, "u2"),
_msg("assistant", "reply2", 201.0, "a2"),
],
)
session.save()
# Step 1: Retry — truncates to before "second"
retry_last(sid)
loaded = Session.load(sid)
assert loaded.truncation_watermark == 101.0
assert [m["content"] for m in loaded.messages] == ["first", "reply"]
# Step 2: Send new message
_checkpoint_user(loaded, "new question", started_at=300.0, monkeypatch=monkeypatch)
loaded.save()
reloaded = Session.load(sid)
assert reloaded.truncation_watermark is not None
assert reloaded.truncation_watermark == 300.0
def test_undo_then_new_turn_watermark_advances(monkeypatch, tmp_path):
"""After /undo → new turn, watermark should advance (not clear to None)."""
_setup_session_dir(monkeypatch, tmp_path)
from api.session_ops import undo_last
sid = "undo_watermark_advance"
session = Session(
session_id=sid,
messages=[
_msg("user", "first", 100.0, "u1"),
_msg("assistant", "reply", 101.0, "a1"),
_msg("user", "second", 200.0, "u2"),
_msg("assistant", "reply2", 201.0, "a2"),
],
)
session.save()
# Step 1: Undo — removes "second" and "reply2"
undo_last(sid)
loaded = Session.load(sid)
assert loaded.truncation_watermark == 101.0
assert [m["content"] for m in loaded.messages] == ["first", "reply"]
# Step 2: Send new message
_checkpoint_user(loaded, "new question", started_at=300.0, monkeypatch=monkeypatch)
loaded.save()
reloaded = Session.load(sid)
assert reloaded.truncation_watermark is not None
assert reloaded.truncation_watermark == 300.0
def test_zero_watermark_preserved_through_new_turn(monkeypatch, tmp_path):
"""The 0.0 truncate-to-empty sentinel (#2914) must survive a new turn —
the falsy guard prevents advancing it."""
_setup_session_dir(monkeypatch, tmp_path)
sid = "zero_watermark_preserved"
session = Session(
session_id=sid,
messages=[],
truncation_watermark=0.0,
)
session.save()
loaded = Session.load(sid)
assert loaded.truncation_watermark == 0.0
# Send new message — 0.0 must NOT be advanced
_checkpoint_user(loaded, "new msg", started_at=100.0, monkeypatch=monkeypatch)
loaded.save()
reloaded = Session.load(sid)
assert reloaded.truncation_watermark == 0.0, (
"0.0 truncate-to-empty sentinel was advanced — #2914 regression!"
)