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:
parent
9d4cc12605
commit
9284a3402f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
Loading…
Reference in New Issue