f4896015c1
Assistant-role compaction summaries were treated as the last visible assistant reply after head protection decayed. That pulled the tail boundary back to the summary itself and left zero new turns to summarize. Exclude internal context summaries from both the visible-reply search and the assistant fallback, mirroring the existing user-role summary exclusion.
601 lines
26 KiB
Python
601 lines
26 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_skips_assistant_role_context_summary_marker(self, compressor):
|
|
"""A persisted assistant-role handoff is internal continuity state,
|
|
not the last reply the user saw. Tool-call-only assistant messages
|
|
after it must remain eligible for the fallback anchor."""
|
|
from agent.context_compressor import SUMMARY_PREFIX
|
|
|
|
messages = [
|
|
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\nold handoff"},
|
|
{"role": "user", "content": "continue the task"},
|
|
{"role": "assistant", "content": None,
|
|
"tool_calls": [{"function": {"name": "t",
|
|
"arguments": "{}"}}]},
|
|
{"role": "tool", "content": "result", "tool_call_id": "c1"},
|
|
]
|
|
assert compressor._find_last_assistant_message_idx(
|
|
messages, head_end=0
|
|
) == 2
|
|
|
|
def test_all_assistant_messages_are_summaries_returns_minus_one(self, compressor):
|
|
from agent.context_compressor import SUMMARY_PREFIX
|
|
|
|
messages = [
|
|
{"role": "assistant", "content": f"{SUMMARY_PREFIX}\nold handoff"},
|
|
{"role": "user", "content": "continue the task"},
|
|
]
|
|
assert compressor._find_last_assistant_message_idx(
|
|
messages, head_end=0
|
|
) == -1
|
|
|
|
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
|