fix(tui-gateway): parse partial compress args in /compress here [N]

_mirror_slash_side_effects() passed the raw argument after /compress
directly as focus_topic to _compress_session_history. So /compress here
3 silently used "here 3" as a summary focus topic and did a full
compress instead of preserving the last 3 exchanges verbatim.

Fix: call parse_partial_compress_args() on the argument first. When it
detects a boundary-aware form (here, here N, up to here, --keep N),
split the history into head/tail using split_history_for_partial_compress,
compress only the head via agent._compress_context, and rejoin with
rejoin_compressed_head_and_tail — exactly mirroring cli.py and
gateway/run.py's /compress here implementation (PR #35252).

Non-boundary forms (plain /compress, /compress <focus>) fall through
to _compress_session_history unchanged.
This commit is contained in:
AhmetArif0 2026-05-31 00:17:07 +03:00 committed by Teknium
parent 9d4cc12605
commit 9284a3402f
2 changed files with 177 additions and 1 deletions

View File

@ -8009,6 +8009,152 @@ def test_mirror_slash_compress_does_not_prelock_history(monkeypatch):
assert "tokens" in warning
def test_mirror_slash_compress_here_triggers_partial_compress(monkeypatch):
"""/compress here [N] must split history into head/tail and rejoin after
compression the partial_compress module is used, not full compress.
Before this fix, /compress here 3 passed "here 3" as focus_topic to the
full compress, silently ignoring the boundary intent.
"""
import types
_FAKE_HISTORY = [
{"role": "user", "content": "msg1"},
{"role": "assistant", "content": "resp1"},
{"role": "user", "content": "msg2"},
{"role": "assistant", "content": "resp2"},
{"role": "user", "content": "keep this"},
{"role": "assistant", "content": "keep this too"},
]
_COMPRESSED_HEAD = [{"role": "user", "content": "[summary]"},
{"role": "assistant", "content": "ok"}]
_REJOINED = _COMPRESSED_HEAD + _FAKE_HISTORY[-2:]
compress_context_calls = []
rejoin_calls = []
full_compress_calls = []
agent = types.SimpleNamespace(
_cached_system_prompt=None,
tools=None,
session_id="s1",
)
def _fake_compress_context(history, sys, approx_tokens=0, focus_topic=None, **kw):
compress_context_calls.append((list(history), focus_topic))
return _COMPRESSED_HEAD, {}
agent._compress_context = _fake_compress_context
def _fake_full_compress(session, focus_topic=None, **_kw):
full_compress_calls.append(focus_topic)
return (0, {})
def _fake_rejoin(head, tail):
rejoin_calls.append((list(head), list(tail)))
return head + tail
monkeypatch.setattr(server, "_compress_session_history", _fake_full_compress)
monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **kw: None)
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
monkeypatch.setattr(server, "_emit", lambda *args: None)
monkeypatch.setattr(
"hermes_cli.partial_compress.rejoin_compressed_head_and_tail",
_fake_rejoin,
)
import threading
lock = threading.Lock()
session = _session(running=False)
session["history_lock"] = lock
session["history"] = list(_FAKE_HISTORY)
session["history_version"] = 7
session["agent"] = agent
warning = server._mirror_slash_side_effects("sid", session, "/compress here 1")
assert warning == ""
# Partial compress must NOT fall back to full _compress_session_history
assert full_compress_calls == [], (
"full compress was called — partial compress path not taken"
)
# agent._compress_context must have been called with the HEAD only
assert len(compress_context_calls) == 1
head_passed, focus_passed = compress_context_calls[0]
assert focus_passed is None # partial compress has no focus topic
# rejoin must have been called to re-attach the tail
assert len(rejoin_calls) == 1
# Session history must now contain the rejoined transcript
assert session["history"] == _REJOINED
assert session["history_version"] == 8
def test_mirror_slash_compress_here_falls_back_on_degenerate_split(monkeypatch):
"""/compress here on a very short history with keep_last >= exchanges
produces an empty tail must fall back to full compress."""
import types
# Only 2 messages — keep_last=5 means nothing to compress
_SHORT_HISTORY = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
full_compress_calls = []
agent = types.SimpleNamespace(
_cached_system_prompt=None, tools=None, session_id="s1"
)
def _fake_full_compress(session, focus_topic=None, **_kw):
full_compress_calls.append(focus_topic)
return (0, {})
monkeypatch.setattr(server, "_compress_session_history", _fake_full_compress)
monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **kw: None)
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
monkeypatch.setattr(server, "_emit", lambda *args: None)
import threading
session = _session(running=False)
session["history_lock"] = threading.Lock()
session["history"] = list(_SHORT_HISTORY)
session["history_version"] = 0
session["agent"] = agent
server._mirror_slash_side_effects("sid", session, "/compress here 5")
# Degenerate split → full compress, focus_topic=None
assert full_compress_calls == [None]
def test_mirror_slash_compress_plain_focus_topic_not_parsed_as_partial(monkeypatch):
"""/compress my topic must still do full compress with focus_topic set."""
import types
full_compress_calls = []
agent = types.SimpleNamespace(session_id="s1")
def _fake_full_compress(session, focus_topic=None, **_kw):
full_compress_calls.append(focus_topic)
return (0, {})
monkeypatch.setattr(server, "_compress_session_history", _fake_full_compress)
monkeypatch.setattr(server, "_sync_session_key_after_compress", lambda *a, **kw: None)
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"})
monkeypatch.setattr(server, "_emit", lambda *args: None)
import threading
session = _session(running=False)
session["history_lock"] = threading.Lock()
session["history"] = []
session["history_version"] = 0
session["agent"] = agent
server._mirror_slash_side_effects("sid", session, "/compress my topic")
assert full_compress_calls == ["my topic"]
# ---------------------------------------------------------------------------
# session.create / session.close race: fast /new churn must not orphan the
# slash_worker subprocess or the global approval-notify registration.

View File

@ -15446,9 +15446,15 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
from agent.conversation_compression import (
finalize_context_engine_compression_notification,
)
from hermes_cli.partial_compress import (
parse_partial_compress_args,
rejoin_compressed_head_and_tail,
split_history_for_partial_compress,
)
with session["history_lock"]:
_before_messages = list(session.get("history", []))
_hv = int(session.get("history_version", 0))
_before_count = len(_before_messages)
_sys_prompt = getattr(agent, "_cached_system_prompt", "") or ""
_tools = getattr(agent, "tools", None) or None
@ -15460,7 +15466,31 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str:
else 0
)
_compress_session_history(session, arg)
# Boundary-aware forms (here [N], up to here, --keep N) split the
# history and compress only the head, keeping the most recent
# exchanges verbatim — mirroring cli.py and gateway/run.py's
# /compress here implementation (PR #35252). Before this fix the
# raw argument was passed straight through as a focus topic, so
# "/compress here 3" silently did a FULL compress focused on the
# literal text "here 3".
partial, keep_last, focus_topic = parse_partial_compress_args(arg or "")
if partial:
head, tail = split_history_for_partial_compress(
_before_messages, keep_last
)
if not tail:
partial = False # degenerate split — fall back to full compress
else:
_compressed_head, _ = agent._compress_context(
head, None, approx_tokens=_before_tokens, focus_topic=None
)
_rejoined = rejoin_compressed_head_and_tail(_compressed_head, tail)
with session["history_lock"]:
if int(session.get("history_version", 0)) == _hv:
session["history"] = _rejoined
session["history_version"] = _hv + 1
if not partial:
_compress_session_history(session, focus_topic)
_sync_session_key_after_compress(sid, session)
with session["history_lock"]: