Files
nousresearch--hermes-agent/tests/agent/test_compressor_assistant_tail_anchor.py
T
wehub-resource-sync b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:56:03 +08:00

572 lines
25 KiB
Python

"""Regression coverage for #29824 — the WebUI session viewer (and TUI
chat panel) was showing the ``[CONTEXT COMPACTION — REFERENCE ONLY]``
handoff block in the slot where the user had just been reading the
assistant's actual reply, because the previously-visible reply got
rolled into the compaction summary by the token-budget tail walk.
The fix adds ``_ensure_last_assistant_message_in_tail`` — a mirror of
the existing ``_ensure_last_user_message_in_tail`` (#10896 anchor) —
that pulls ``cut_idx`` back to include the most recent assistant
message with non-empty text content, with the standard tool-group
realignment so we don't orphan a ``tool_call`` / ``tool_result`` pair.
Pinned here:
* ``TestFindLastAssistantMessageIdx`` — pure helper contract:
finds the most recent **content-bearing** assistant message,
skips tool-call-only stubs, falls back to "any assistant" only
when no content-bearing reply exists in the compressible region,
honours ``head_end``, returns -1 when there's no assistant at all.
* ``TestEnsureLastAssistantMessageInTail`` — direct: walks
``cut_idx`` back when the last reply is in the compressed middle,
is a no-op when it's already in the tail, never crosses
``head_end``, re-aligns through tool groups.
* ``TestFindTailCutByTokensAnchorsAssistant`` — integration with
the existing tail-cut path: the exact reporter scenario (long
tool-output run after the previously-visible reply) preserves
the reply; combines with the user anchor for the same-turn
preservation; soft-ceiling overrun no longer hides the reply.
* ``TestCompactionRollupReproduction`` — end-to-end through
``compress()`` with a stubbed summariser: pre-fix the reply
text is absorbed into the summary (regression demonstrated by
asserting on the OLD behaviour fails); post-fix the reply text
is still present in the compressed transcript as a regular
assistant message.
* ``TestSourceGuardrail`` — static asserts on
``agent/context_compressor.py`` so a future refactor can't
silently drop the anchor.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
@pytest.fixture()
def compressor():
"""ContextCompressor with mocked deps and a tight tail budget so
the helpers' anchor behaviour is observable."""
from agent.context_compressor import ContextCompressor
with patch(
"agent.context_compressor.get_model_context_length",
return_value=100_000,
):
c = ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=2,
protect_last_n=2,
quiet_mode=True,
)
c.tail_token_budget = 50
return c
# ---------------------------------------------------------------------------
# Helper: _find_last_assistant_message_idx
# ---------------------------------------------------------------------------
class TestFindLastAssistantMessageIdx:
def test_finds_content_bearing_assistant(self, compressor):
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "q"},
{"role": "assistant", "content": "the reply"},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=1)
assert idx == 2
def test_skips_tool_call_only_stub_when_text_reply_exists_earlier(
self, compressor
):
"""An assistant message that only carries ``tool_calls`` (no
text content) is not the user-visible reply — the WebUI
renders those as small "calling tool X" indicators. The helper
must prefer the earlier text reply, which is what the user
actually read."""
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "VISIBLE REPLY"},
{"role": "user", "content": "q2"},
{"role": "assistant", "content": None,
"tool_calls": [{"function": {"name": "t",
"arguments": "{}"}}]},
{"role": "tool", "content": "result", "tool_call_id": "c1"},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
assert idx == 1, (
"Expected the content-bearing assistant reply (1), not the "
f"trailing tool-call stub. Got {idx}."
)
def test_empty_string_content_does_not_count_as_visible(self, compressor):
"""An assistant message with ``content=""`` (only whitespace)
is not a visible reply either — common pre-flight stub before
the model streams the real answer."""
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "earlier reply"},
{"role": "user", "content": "q2"},
{"role": "assistant", "content": " "}, # blank stub
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
# Blank-string assistant message does not count — fall back
# to the earlier real reply.
assert idx == 1
def test_multimodal_text_block_counts(self, compressor):
"""An assistant with multimodal list-content carrying a text
block (Anthropic / GPT-style ``[{type:text,text:...}]``)
counts as content-bearing."""
messages = [
{"role": "user", "content": "q"},
{"role": "assistant",
"content": [{"type": "text", "text": "hello"}]},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
assert idx == 1
def test_fallback_to_any_assistant_when_no_content_bearing(
self, compressor
):
"""When there's no text-bearing assistant in the compressible
region (fresh multi-step tool sequence), fall back to the
most recent assistant of any kind so the anchor still works."""
messages = [
{"role": "user", "content": "q"},
{"role": "assistant", "content": None,
"tool_calls": [{"function": {"name": "t",
"arguments": "{}"}}]},
{"role": "tool", "content": "result", "tool_call_id": "c1"},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
assert idx == 1
def test_returns_negative_one_when_no_assistant(self, compressor):
messages = [
{"role": "user", "content": "q1"},
{"role": "user", "content": "q2"},
]
idx = compressor._find_last_assistant_message_idx(messages, head_end=0)
assert idx == -1
def test_respects_head_end_lower_bound(self, compressor):
"""An assistant message at or before ``head_end`` must be
ignored — it's already in the protected head region."""
messages = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": "in-head reply"}, # idx 1
{"role": "user", "content": "q"},
]
# head_end=2 means the compressible region starts at index 2;
# the assistant at index 1 is in the head and must be skipped.
idx = compressor._find_last_assistant_message_idx(messages, head_end=2)
assert idx == -1
# ---------------------------------------------------------------------------
# Helper: _ensure_last_assistant_message_in_tail
# ---------------------------------------------------------------------------
class TestEnsureLastAssistantMessageInTail:
def test_no_op_when_already_in_tail(self, compressor):
messages = [
{"role": "user", "content": "q"},
{"role": "assistant", "content": "reply"},
{"role": "user", "content": "q2"},
]
# cut_idx=1 means tail starts at index 1 — the reply is already in tail.
new_cut = compressor._ensure_last_assistant_message_in_tail(
messages, cut_idx=1, head_end=0
)
assert new_cut == 1
def test_walks_cut_idx_back_to_include_reply(self, compressor):
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "REPLY"}, # idx 1
{"role": "user", "content": "q2"},
{"role": "user", "content": "q3"},
]
# cut_idx=2 leaves the reply outside the tail; anchor must pull
# cut_idx back to 1 so messages[1:] contains the reply.
new_cut = compressor._ensure_last_assistant_message_in_tail(
messages, cut_idx=2, head_end=0
)
assert new_cut == 1
assert any(
isinstance(m.get("content"), str) and "REPLY" in m["content"]
for m in messages[new_cut:]
)
def test_never_crosses_head_end(self, compressor):
messages = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": "in-head"}, # head, must ignore
{"role": "user", "content": "q"},
]
# head_end=2 ⇒ assistant at idx 1 is in the head; the anchor
# finds nothing in the compressible region and is a no-op.
new_cut = compressor._ensure_last_assistant_message_in_tail(
messages, cut_idx=3, head_end=2
)
assert new_cut == 3
def test_re_aligns_through_preceding_tool_group(self, compressor):
"""When the anchored assistant is preceded by a
tool_call/result group, ``_align_boundary_backward`` must pull
``cut_idx`` even further back so the group isn't split — same
guarantee as ``_ensure_last_user_message_in_tail``."""
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1",
"function": {"name": "t",
"arguments": "{}"}}]},
{"role": "tool", "content": "result", "tool_call_id": "c1"},
{"role": "assistant", "content": "REPLY"}, # idx 3
{"role": "user", "content": "q2"},
]
# cut_idx=4 leaves the reply outside the tail. Anchor pulls
# back to 3, then _align_boundary_backward sees the preceding
# tool group and pulls further back to 1 (before the assistant
# with tool_calls).
new_cut = compressor._ensure_last_assistant_message_in_tail(
messages, cut_idx=4, head_end=0
)
assert new_cut <= 3
# The tool_call assistant (1) and its tool_result (2) must NOT
# be split: either both in compressed region or both in tail.
if new_cut <= 1:
# Both in tail — tool group intact.
assert messages[new_cut].get("role") == "assistant"
else:
# Otherwise the anchor must land at the reply itself (3).
assert new_cut == 3
# ---------------------------------------------------------------------------
# Integration with _find_tail_cut_by_tokens
# ---------------------------------------------------------------------------
class TestFindTailCutByTokensAnchorsAssistant:
def test_reporter_repro_long_tool_run_after_visible_reply(
self, compressor
):
"""The exact #29824 scenario: a tight token budget combined
with a long tail of tool-call/result messages after the
visible reply. Pre-fix, the token-budget walk hit its ceiling
on the tool output and parked ``cut_idx`` past the reply.
Post-fix, the assistant anchor pulls it back."""
c = compressor
c.tail_token_budget = 10 # force min-tail behaviour
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "msg1"}, # head_end=2
{"role": "user", "content": "q1"},
{"role": "assistant",
"content": "PREVIOUSLY VISIBLE REPLY"}, # idx 3
{"role": "user", "content": "q2"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1",
"function": {"name": "t",
"arguments": "{}"}}]},
{"role": "tool", "content": "x" * 200,
"tool_call_id": "c1"},
]
cut = c._find_tail_cut_by_tokens(messages, head_end=2)
tail_contents = [
m.get("content") for m in messages[cut:]
if isinstance(m.get("content"), str)
]
assert any(
"PREVIOUSLY VISIBLE REPLY" in (t or "") for t in tail_contents
), (
"REGRESSION (#29824): the visible reply was rolled into "
f"the compaction summary. Tail contents: {tail_contents!r}"
)
def test_user_and_assistant_anchors_compose(self, compressor):
"""Both anchors run in sequence; the tail must contain both
the latest user message AND the latest visible assistant
reply."""
c = compressor
c.tail_token_budget = 10
messages = [
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "VISIBLE REPLY"},
{"role": "user", "content": "follow-up question"},
{"role": "user", "content": "and another"},
]
cut = c._find_tail_cut_by_tokens(messages, head_end=0)
tail_contents = [
m.get("content") for m in messages[cut:]
if isinstance(m.get("content"), str)
]
assert any("VISIBLE REPLY" in (t or "") for t in tail_contents)
assert any("and another" in (t or "") for t in tail_contents)
def test_oversized_tool_output_does_not_strand_reply(self, compressor):
"""The soft-ceiling logic in ``_find_tail_cut_by_tokens``
permits a single oversized tail message; the assistant anchor
must still recover the reply on the other side of it."""
c = compressor
c.tail_token_budget = 100 # soft ceiling 150
messages = [
{"role": "user", "content": "earlier"},
{"role": "assistant", "content": "VISIBLE REPLY"},
{"role": "user", "content": "read big file"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1",
"function": {"name": "read",
"arguments": "{}"}}]},
# ~500 chars ⇒ ~135 tokens, blows past soft ceiling of 150
{"role": "tool", "content": "y" * 500,
"tool_call_id": "c1"},
{"role": "user", "content": "ok"},
]
cut = c._find_tail_cut_by_tokens(messages, head_end=0)
tail_contents = [
m.get("content") for m in messages[cut:]
if isinstance(m.get("content"), str)
]
assert any("VISIBLE REPLY" in (t or "") for t in tail_contents)
# ---------------------------------------------------------------------------
# End-to-end: compress() preserves the reply
# ---------------------------------------------------------------------------
class TestCompactionRollupReproduction:
"""End-to-end through ``compress()``: the visible reply text must
survive in the compressed transcript — either as its own
standalone assistant message OR concatenated onto the merged
summary-handoff tail message (the compressor's double-collision
fallback path; the WebUI re-splits these on the END marker so the
reply renders as a separate bubble — see ``splitCompactionContent``
in ``web/src/pages/SessionsPage.tsx``)."""
def test_compress_keeps_visible_reply_text(self, compressor):
from agent.context_compressor import (
SUMMARY_PREFIX,
COMPRESSED_SUMMARY_METADATA_KEY,
)
c = compressor
c.tail_token_budget = 10
# ``_generate_summary`` normally wraps the LLM body in
# ``SUMMARY_PREFIX`` via ``_with_summary_prefix``; mimic that so
# the merge-into-tail branch can identify the boundary.
_mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary"
messages = (
[{"role": "system", "content": "sys"},
{"role": "user", "content": "initial"}] # head (protect_first_n=2)
# Middle: long enough to be compressible.
+ [
{"role": "user", "content": f"middle q{i}"}
if i % 2 == 0
else {"role": "assistant", "content": f"middle reply {i}"}
for i in range(12)
]
+ [
{"role": "user", "content": "the visible question"},
{"role": "assistant",
"content": "THE VISIBLE REPLY THE USER JUST READ"},
{"role": "user", "content": "follow up"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "c1",
"function": {"name": "t",
"arguments": "{}"}}]},
{"role": "tool", "content": "z" * 500,
"tool_call_id": "c1"},
]
)
with patch.object(
c, "_generate_summary",
return_value=_mocked,
):
result = c.compress(messages, current_tokens=90_000)
# 1. A summary message exists (compression actually ran). Detect via
# the canonical metadata key rather than a content prefix: merge-into-
# tail summaries wrap prior content before the summary, so the prefix
# is no longer at the start of the message content (#56372).
assert any(
m.get(COMPRESSED_SUMMARY_METADATA_KEY)
for m in result
), "compress() did not insert a summary message"
# 2. The visible reply text must survive somewhere — either
# as its own message OR concatenated into the merged tail.
joined = "\n".join(
m.get("content") for m in result
if isinstance(m.get("content"), str)
)
assert "THE VISIBLE REPLY THE USER JUST READ" in joined, (
"REGRESSION (#29824): the visible reply was absorbed into "
"the compaction summary AND erased. Compressed transcript "
f"({len(result)} msgs): "
f"{[(m.get('role'), str(m.get('content'))[:50]) for m in result]}"
)
def test_standalone_summary_case_keeps_reply_as_own_message(
self, compressor
):
"""When the head and tail roles allow a standalone summary
message (no double-collision), the visible reply must remain
as its OWN assistant message — not merged with anything.
This is the common case; the merge-into-tail path is the
edge case for double-collision."""
from agent.context_compressor import (
SUMMARY_PREFIX,
COMPRESSED_SUMMARY_METADATA_KEY,
)
c = compressor
c.tail_token_budget = 10
_mocked = f"{SUMMARY_PREFIX}\nrolled-up middle summary"
# Head ends with ``assistant`` ⇒ summary_role flips to
# ``user`` ⇒ no collision with the assistant tail ⇒ standalone
# summary insert (no merge).
messages = (
[
{"role": "user", "content": "initial"},
{"role": "assistant", "content": "head reply"},
]
+ [
{"role": "user", "content": f"middle q{i}"}
if i % 2 == 0
else {"role": "assistant", "content": f"middle reply {i}"}
for i in range(12)
]
+ [
{"role": "user", "content": "the visible question"},
{"role": "assistant",
"content": "THE VISIBLE REPLY THE USER JUST READ"},
{"role": "user", "content": "follow up"},
]
)
with patch.object(
c, "_generate_summary",
return_value=_mocked,
):
result = c.compress(messages, current_tokens=90_000)
# Summary present (detect via the canonical metadata key — merge-into-
# tail summaries no longer start with SUMMARY_PREFIX after #56372):
summary_rows = [
m for m in result
if m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
assert len(summary_rows) == 1
# Visible reply as its OWN distinct assistant message
# (NOT merged into the summary row):
reply_rows = [
m for m in result
if m.get("role") == "assistant"
and isinstance(m.get("content"), str)
and "THE VISIBLE REPLY THE USER JUST READ" in m["content"]
and not m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
assert len(reply_rows) == 1, (
"REGRESSION (#29824): expected exactly one standalone "
f"assistant message carrying the visible reply, got "
f"{len(reply_rows)}"
)
# ---------------------------------------------------------------------------
# Source guardrail
# ---------------------------------------------------------------------------
class TestFindLastUserMessageIdxSkipsSummaryMarker:
"""A context-compaction handoff banner is inserted with ``role="user"``
when the head ends in an assistant/tool message (see the summary-role
selection in ``compress``). ``_find_last_user_message_idx`` must NOT treat
that banner as the latest user turn — otherwise, on a resumed or
multi-compaction session, ``_ensure_last_user_message_in_tail`` anchors the
tail to the summary and rolls the genuine last user message into the next
compaction, re-triggering the active-task loss the anchor exists to prevent.
(Salvaged from #36626 / issue #36624.)
"""
def test_skips_user_role_context_summary_marker(self, compressor):
from agent.context_compressor import SUMMARY_PREFIX
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "REAL current task"},
{"role": "assistant", "content": "working on it"},
# A handoff summary re-inserted as a user-role message after resume.
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nold"},
{"role": "assistant", "content": "continuing from the real task"},
]
# Latest *real* user message is index 1, not the summary at index 3.
assert compressor._find_last_user_message_idx(messages, head_end=1) == 1
def test_returns_real_user_when_no_summary_present(self, compressor):
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "reply"},
{"role": "user", "content": "second"},
]
assert compressor._find_last_user_message_idx(messages, head_end=1) == 3
def test_all_user_messages_are_summaries_returns_minus_one(self, compressor):
from agent.context_compressor import SUMMARY_PREFIX
messages = [
{"role": "system", "content": "sys"},
{"role": "assistant", "content": "reply"},
{"role": "user", "content": f"{SUMMARY_PREFIX}\nhandoff"},
]
assert compressor._find_last_user_message_idx(messages, head_end=1) == -1
class TestSourceGuardrail:
@pytest.fixture
def source(self) -> str:
from pathlib import Path
return (Path(__file__).resolve().parents[2]
/ "agent" / "context_compressor.py").read_text(
encoding="utf-8")
def test_helper_defined(self, source):
assert "def _find_last_assistant_message_idx(" in source
assert "def _ensure_last_assistant_message_in_tail(" in source
def test_anchor_called_from_find_tail_cut(self, source):
"""Without the call site the helper is dead code and the bug
regresses silently — pin both the definition AND the wiring."""
assert "self._ensure_last_assistant_message_in_tail(" in source
def test_anchor_called_after_user_anchor(self, source):
"""The two anchors must run in sequence; reversing or skipping
one drops the corresponding side of the guarantee."""
user_call = "self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)"
asst_call = "self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)"
user_idx = source.find(user_call)
asst_idx = source.find(asst_call)
assert user_idx >= 0 and asst_idx >= 0
assert asst_idx > user_idx, (
"The assistant anchor must come AFTER the user anchor in "
"``_find_tail_cut_by_tokens`` — each anchor walks cut_idx "
"backward, and ordering keeps the chain monotonic."
)
def test_helper_prefers_content_bearing_reply(self, source):
"""The helper must skip tool-call-only stubs — that's the
whole user-experience difference between #29824 (no visible
reply) and an in-progress turn (small 'calling tool X' chip)."""
assert "content.strip()" in source
def test_issue_number_referenced(self, source):
assert "#29824" in source