Some checks failed
Release & Docker / release (push) Has been cancelled
## 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>
168 lines
8.3 KiB
Python
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)"
|