Files
hermes-webui/tests/test_issue3468_duplicate_after_compression.py
nesquena-hermes aae584ba57
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.235 — Release HC (stage-q5) (#3490)
## Release v0.51.235 — Release HC (stage-q5)

Single fix in the data-sensitive transcript-merge path, with a Codex-found MUST-FIX applied.

### Fix
| PR | Author | Fix |
|----|--------|-----|
| #3468 | @jasonjcwu | `_find_current_user_turn` returns the **last** matching user turn instead of the first, so post-compression `result_messages` (which carries the full history) no longer replays the entire conversation when the user repeats a similar question (137-msg session → 89 duplicate replays). |

### Pre-release review caught a CORE issue (fixed before ship)
Codex's regression gate found that a naive last-match could be overridden by a **synthetic `role:"user"` continuation prompt** (the agent loop injects "Continue"/empty-recovery nudges — verified at `conversation_loop.py:1763/4183/4356`) that only *substring*-matches the user text — anchoring the merge **past** the real turn and dropping the assistant/tool output in between. Applied the fix: track **strong** (exact `_looks_like_current_user_turn`) and **weak** (substring) matches separately and return `last_strong → last_weak → fallback`. The real turn (strong) always wins over a later synthetic continuation (weak).

Opus reviewed the original and said ship; the applied fix is strictly safer than what it reviewed.

### Gate results
- **Full pytest suite**: 7465 passed, 8 skipped, 3 xpassed, **0 failed**
- **ruff forward gate**: CLEAN
- **browser-smoke gate**: CLEAN
- **Codex (regression)**: SHIP ONLY WITH FIXES → fix applied → re-reviewed **SAFE TO SHIP**
- **Opus (correctness)**: SHIP IT (on the pre-fix code; applied fix is strictly safer)
- **Regression test** (`tests/test_issue3468_duplicate_after_compression.py`, 7 cases): pins the last-match behavior, the strong-beats-later-weak invariant, and the end-to-end no-duplicate-replay invariant — each **verified to fail against the pre-fix logic**.

Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
2026-06-03 10:11:32 -07:00

168 lines
8.3 KiB
Python

"""Regression: repeated/similar user turns must not replay the whole history
after context compression (#3468).
``_find_current_user_turn`` locates the index in the agent's ``result_messages``
from which the current turn's new messages begin; the merge then takes
``result_messages[idx:]`` as the turn candidates. After one or more context
compression cycles, ``result_messages`` carries the FULL conversation history.
When the user asks a similar (or identical) question across turns, a *first*-match
scan returns the OLD turn's index, so the candidate slice includes the entire
replayed history and the merged transcript accumulates duplicate messages
(observed: a 137-message session where 89 were duplicate replays).
The fix returns the *last* matching user turn, so the candidate slice begins at
the current turn and the replayed history is not re-appended. These tests pin
that behavior at both the unit level (``_find_current_user_turn``) and the
integration level (``_merge_display_messages_after_agent_result``).
"""
from __future__ import annotations
from api.streaming import (
_find_current_user_turn,
_merge_display_messages_after_agent_result,
)
def test_find_current_user_turn_returns_last_matching_turn():
"""With the same user text appearing in an earlier AND the current turn,
the function must return the LAST occurrence, not the first."""
messages = [
{"role": "user", "content": "recommend a stock"},
{"role": "assistant", "content": "Here is an older answer."},
{"role": "user", "content": "something else"},
{"role": "assistant", "content": "Sure."},
{"role": "user", "content": "recommend a stock"}, # current turn
]
idx = _find_current_user_turn(messages, "recommend a stock")
assert idx == 4, f"expected last matching user turn at index 4, got {idx}"
def test_find_current_user_turn_first_match_would_replay_history():
"""Demonstrate the bug shape: first-match would point at index 0 and the
slice result_messages[0:] would replay everything. Last-match points at the
current turn so the slice is just the current turn forward."""
messages = [
{"role": "user", "content": "推荐一下股票"},
{"role": "assistant", "content": "old recommendation"},
{"role": "user", "content": "推荐一下股票"}, # current turn, identical text
]
idx = _find_current_user_turn(messages, "推荐一下股票")
assert idx == 2
# The slice taken by the merge starts at the CURRENT turn, not the old one.
assert messages[idx:] == [{"role": "user", "content": "推荐一下股票"}]
def test_find_current_user_turn_fallback_when_no_match():
"""When no user message matches the needle, fall back to the last user
index (existing behavior), not None, so the merge still has an anchor."""
messages = [
{"role": "user", "content": "alpha"},
{"role": "assistant", "content": "a"},
{"role": "user", "content": "beta"},
{"role": "assistant", "content": "b"},
]
idx = _find_current_user_turn(messages, "totally different text")
# fallback = last user index seen (2)
assert idx == 2
def test_find_current_user_turn_none_when_no_user_messages():
messages = [
{"role": "assistant", "content": "a"},
{"role": "tool", "content": "t"},
]
assert _find_current_user_turn(messages, "anything") is None
def test_strong_match_beats_later_synthetic_weak_match():
"""A later synthetic continuation prompt (agent-injected role:'user' "Continue"
turns — see conversation_loop.py) only WEAK-matches the submitted text. The
real current turn (an exact / strong match) must win even though the synthetic
turn comes later, otherwise the merge anchors PAST the real turn and drops the
assistant/tool output produced in between. (Codex pre-release finding, #3468.)"""
msg_text = "Continue"
messages = [
{"role": "user", "content": "Continue"}, # idx 0 — real turn (strong/exact)
{"role": "assistant", "content": "Working on it"}, # idx 1 — must be preserved
{"role": "tool", "content": "tool output"}, # idx 2 — must be preserved
# idx 3 — synthetic continuation prompt: CONTAINS "Continue" (weak match only)
{"role": "user", "content": "[System: Continue now and finish the task. Continue]"},
]
idx = _find_current_user_turn(messages, msg_text)
# Must anchor on the REAL turn (idx 0, strong), not the synthetic one (idx 3, weak).
assert idx == 0, f"strong match at 0 must beat later weak match at 3, got {idx}"
# The slice from the real turn preserves the assistant + tool output.
assert {m["role"] for m in messages[idx:]} >= {"assistant", "tool"}
def test_weak_match_used_when_no_strong_match():
"""When there's no exact (strong) match, fall back to the LAST weak substring
match (still better than first-match for the repeated-question replay bug)."""
msg_text = "recommend a stock"
messages = [
# idx 0 — weak: needle is a substring of this older, longer turn
{"role": "user", "content": "recommend a stock for me"},
{"role": "assistant", "content": "old"},
# idx 2 — weak: needle is a substring of this later, longer turn
{"role": "user", "content": "recommend a stock today"},
]
# No message equals msg_text exactly, so no strong match; both are weak
# (needle in text). Must return the LAST weak match (idx 2), not the first.
idx = _find_current_user_turn(messages, msg_text)
assert idx == 2, f"expected last weak match at 2, got {idx}"
def test_merge_no_duplicate_replay_on_repeated_question_after_compression():
"""Integration: the user asks the same question in turn 1 and turn 3. After
compression result_messages carries the full history. The merged transcript
must NOT re-append the turn-1 history — duplicates must not accumulate."""
# The visible transcript so far (what the browser already shows).
previous_display = [
{"role": "user", "content": "recommend a stock"},
{"role": "assistant", "content": "Older: consider an index fund."},
{"role": "user", "content": "what about bonds"},
{"role": "assistant", "content": "Bonds are lower risk."},
]
# Context is empty / does not prefix-match result_messages, forcing the
# _find_current_user_turn path (the branch the fix touches).
previous_context: list[dict] = []
# After compression the agent returns the FULL history plus the new turn.
result_messages = [
{"role": "user", "content": "recommend a stock"},
{"role": "assistant", "content": "Older: consider an index fund."},
{"role": "user", "content": "what about bonds"},
{"role": "assistant", "content": "Bonds are lower risk."},
{"role": "user", "content": "recommend a stock"}, # current turn (repeat)
{"role": "assistant", "content": "Newer: here is a fresh pick."},
]
msg_text = "recommend a stock"
merged = _merge_display_messages_after_agent_result(
previous_display, previous_context, result_messages, msg_text
)
# The new assistant answer must be present exactly once.
newer = [m for m in merged if m.get("content") == "Newer: here is a fresh pick."]
assert len(newer) == 1, f"new answer should appear once, got {len(newer)}"
# The OLD history must NOT be duplicated: each prior message appears once.
older = [m for m in merged if m.get("content") == "Older: consider an index fund."]
assert len(older) == 1, f"old answer must not be replayed, got {len(older)}"
bonds = [m for m in merged if m.get("content") == "Bonds are lower risk."]
assert len(bonds) == 1, f"intermediate turn must not be replayed, got {len(bonds)}"
# The merged transcript must not balloon: no message identity appears twice
# except the deliberately-repeated user question (which legitimately occurs
# in both turn 1 and turn 3).
from api.streaming import _message_identity
identities = [_message_identity(m) for m in merged]
repeated_user_key = _message_identity({"role": "user", "content": "recommend a stock"})
for ident in set(identities):
count = identities.count(ident)
if ident == repeated_user_key:
assert count <= 2, "the repeated user question may appear at most twice"
else:
assert count == 1, f"message {ident!r} duplicated {count}x (history replay)"