mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
Follow-up to the salvaged #57634 commits: - agent/manual_compression_feedback.py: new describe_compression_lock_skip() — single source of truth for lock-skip wording. A descriptive holder string means another compressor CONFIRMED holds the lock ('already in progress (holder: ...)'); True/None means acquisition failed without a confirmed holder (hermes_state.try_acquire_compression_lock catches sqlite3.Error internally and returns False), so the message says 'could not acquire ... the lock check failed' instead of falsely claiming a concurrent compression is running. - cli.py, gateway/slash_commands.py, tui_gateway/server.py (all three in-process consumers: session.compress RPC, command.dispatch compress branch, slash.exec mirror) now route through the shared helper. - tui_gateway/server.py command.dispatch compress branch: catch CompressionLockHeld explicitly — it previously fell into the generic 'compress failed' error handler. - Deferred-notify contract (#69324): lock-skip discards the pending context-engine notification (committed=False) in _compress_session_history and the CLI path before returning. - tests: lock-skip wording pins per surface, VISIBLE_COMPRESSION_MESSAGES noise-filter carve-outs for both wordings, MagicMock signal opt-outs for sibling tests added on main after the original PR.
349 lines
14 KiB
Python
349 lines
14 KiB
Python
"""Tests for CLI manual compression messaging."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from tests.cli.test_cli_init import _make_cli
|
|
|
|
|
|
def _make_history() -> list[dict[str, str]]:
|
|
return [
|
|
{"role": "user", "content": "one"},
|
|
{"role": "assistant", "content": "two"},
|
|
{"role": "user", "content": "three"},
|
|
{"role": "assistant", "content": "four"},
|
|
]
|
|
|
|
|
|
def test_manual_compress_keeps_tui_composer_editable(capsys):
|
|
"""A follow-up can be drafted and queued while /compress runs."""
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
shell.agent.session_id = shell.session_id
|
|
|
|
observed = {}
|
|
|
|
def compress(*_args, **_kwargs):
|
|
# The classic TUI's TextArea consults this state for its read_only
|
|
# condition. Compression must retain its status spinner without
|
|
# preventing the user from drafting the next prompt.
|
|
observed["running"] = shell._command_running
|
|
observed["blocks_input"] = getattr(shell, "_command_blocks_input", shell._command_running)
|
|
return list(history), ""
|
|
|
|
shell.agent._compress_context.side_effect = compress
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
assert observed == {"running": True, "blocks_input": False}
|
|
|
|
|
|
def test_manual_compress_reports_noop_without_success_banner(capsys):
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
shell.agent.session_id = shell.session_id # no-op compression: no split
|
|
shell.agent._compress_context.return_value = (list(history), "")
|
|
# Explicitly signal this is NOT a lock-skip to avoid MagicMock
|
|
# getattr returning a truthy mock for unset attributes.
|
|
shell.agent._compression_skipped_due_to_lock = False
|
|
|
|
def _estimate(messages, **_kwargs):
|
|
assert messages == history
|
|
return 100
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate):
|
|
shell._manual_compress()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "No changes from compression" in output
|
|
assert "✅ Compressed" not in output
|
|
assert "Approx request size: ~100 tokens (unchanged)" in output
|
|
|
|
|
|
def test_manual_compress_reports_aborted_summary_without_success_banner(capsys):
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
shell.agent.session_id = shell.session_id
|
|
shell.agent.context_compressor._last_compress_aborted = True
|
|
shell.agent.context_compressor._last_summary_fallback_used = False
|
|
shell.agent.context_compressor._last_summary_error = (
|
|
"Provider 'opencode-zen' is set in config.yaml but no API key was found."
|
|
)
|
|
shell.agent._compress_context.return_value = (list(history), "")
|
|
# Explicit non-lock-skip: MagicMock getattr would return a truthy mock.
|
|
shell.agent._compression_skipped_due_to_lock = False
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "⚠️ Compression aborted: 4 messages preserved" in output
|
|
assert "no messages were removed" in output
|
|
assert "no API key was found" in output
|
|
assert "✅ Compressed:" not in output
|
|
|
|
|
|
def test_manual_compress_explains_when_token_estimate_rises(capsys):
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
compressed = [
|
|
history[0],
|
|
{"role": "assistant", "content": "Dense summary that still counts as more tokens."},
|
|
history[-1],
|
|
]
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
shell.agent.session_id = shell.session_id # no-op: no split
|
|
shell.agent._compress_context.return_value = (compressed, "")
|
|
shell.agent._compression_skipped_due_to_lock = False
|
|
|
|
def _estimate(messages, **_kwargs):
|
|
if messages == history:
|
|
return 100
|
|
if messages == compressed:
|
|
return 120
|
|
raise AssertionError(f"unexpected transcript: {messages!r}")
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate):
|
|
shell._manual_compress()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "✅ Compressed: 4 → 3 messages" in output
|
|
assert "Approx request size: ~100 → ~120 tokens" in output
|
|
assert "denser summaries" in output
|
|
|
|
|
|
def test_manual_compress_syncs_session_id_after_split():
|
|
"""Regression for cli.session_id desync after /compress.
|
|
|
|
_compress_context ends the parent session and creates a new child session,
|
|
mutating agent.session_id. Without syncing, cli.session_id still points
|
|
at the ended parent — causing /status, /resume, exit summary, and the
|
|
next end_session() call (e.g. from /resume <id>) to target the wrong row.
|
|
"""
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
old_id = shell.session_id
|
|
new_child_id = "20260101_000000_child1"
|
|
|
|
compressed = [
|
|
{"role": "user", "content": "[summary]"},
|
|
history[-1],
|
|
]
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
# Simulate _compress_context mutating agent.session_id as a side effect.
|
|
def _fake_compress(*args, **kwargs):
|
|
shell.agent.session_id = new_child_id
|
|
return (compressed, "")
|
|
shell.agent._compress_context.side_effect = _fake_compress
|
|
shell.agent._compression_skipped_due_to_lock = False
|
|
shell.agent.session_id = old_id # starts in sync
|
|
shell._pending_title = "stale title"
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
# CLI session_id must now point at the continuation child, not the parent.
|
|
assert shell.session_id == new_child_id
|
|
assert shell.session_id != old_id
|
|
# Pending title must be cleared — titles belong to the parent lineage and
|
|
# get regenerated for the continuation.
|
|
assert shell._pending_title is None
|
|
|
|
|
|
def test_manual_compress_flushes_compressed_history_to_child_session_db():
|
|
"""Manual /compress must persist the handoff in the continuation DB.
|
|
|
|
_compress_context rotates the agent to a new child session and returns a
|
|
compressed transcript whose first messages include the handoff summary. The
|
|
CLI then replaces its in-memory conversation_history with that transcript.
|
|
Because the child DB starts empty, the flush must start from offset 0 rather
|
|
than treating the compressed history as already persisted.
|
|
"""
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
old_id = shell.session_id
|
|
new_child_id = "20260101_000000_child1"
|
|
compressed = [
|
|
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] compacted"},
|
|
history[-1],
|
|
]
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.session_id = old_id
|
|
|
|
def _fake_compress(*args, **kwargs):
|
|
shell.agent.session_id = new_child_id
|
|
return (compressed, "")
|
|
|
|
shell.agent._compress_context.side_effect = _fake_compress
|
|
shell.agent._compression_skipped_due_to_lock = False
|
|
|
|
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
shell.agent._flush_messages_to_session_db.assert_called_once_with(compressed, None)
|
|
|
|
|
|
def test_manual_compress_does_not_flush_full_history_when_session_id_unchanged():
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.session_id = shell.session_id
|
|
shell.agent._compress_context.return_value = (list(history), "")
|
|
shell.agent._compression_skipped_due_to_lock = False
|
|
|
|
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
shell.agent._flush_messages_to_session_db.assert_not_called()
|
|
|
|
|
|
def test_manual_compress_runs_when_auto_compaction_disabled(capsys):
|
|
"""compression.enabled: false disables *automatic* compaction only.
|
|
|
|
Manual /compress must still work: the context-overflow error path
|
|
(agent/conversation_loop.py) explicitly directs users to /compress when
|
|
auto-compaction is off, and the gateway's /compress handler has never
|
|
gated on the flag. Regression for the CLI refusing with "Compression is
|
|
disabled in config."
|
|
"""
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
compressed = [
|
|
{"role": "user", "content": "[summary]"},
|
|
history[-1],
|
|
]
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = False
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
shell.agent.session_id = shell.session_id
|
|
shell.agent._compress_context.return_value = (compressed, "")
|
|
# Explicit non-lock-skip: MagicMock getattr would return a truthy mock.
|
|
shell.agent._compression_skipped_due_to_lock = False
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Compression is disabled" not in output
|
|
shell.agent._compress_context.assert_called_once()
|
|
# Manual compression bypasses the summary-failure cooldown.
|
|
assert shell.agent._compress_context.call_args.kwargs.get("force") is True
|
|
assert shell.conversation_history == compressed
|
|
|
|
|
|
def test_manual_compress_no_sync_when_session_id_unchanged():
|
|
"""If compression is a no-op (agent.session_id didn't change), the CLI
|
|
must NOT clear _pending_title or otherwise disturb session state.
|
|
"""
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
shell.agent.session_id = shell.session_id
|
|
shell.agent._compress_context.return_value = (list(history), "")
|
|
shell.agent._compression_skipped_due_to_lock = False
|
|
shell._pending_title = "keep me"
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
# No split → pending title untouched.
|
|
assert shell._pending_title == "keep me"
|
|
|
|
|
|
def test_manual_compress_shows_lock_skip_without_confirmed_holder(capsys):
|
|
"""When _compress_context skips due to the compression lock WITHOUT a
|
|
confirmed holder (signal=True — acquisition failed but
|
|
get_compression_lock_holder returned nothing, e.g. a SQLite error made
|
|
try_acquire return False), _manual_compress must print the unconfirmed
|
|
lock-skip wording, NOT claim another compression is definitely running,
|
|
and NOT show the misleading "No changes from compression" no-op text."""
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
shell.agent.session_id = shell.session_id
|
|
|
|
# Simulate _compress_context setting the lock-skip signal and
|
|
# returning unchanged messages.
|
|
def _fake_compress(*args, **kwargs):
|
|
shell.agent._compression_skipped_due_to_lock = True
|
|
return (list(history), "")
|
|
|
|
shell.agent._compress_context.side_effect = _fake_compress
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Compression skipped" in output
|
|
assert "could not acquire" in output
|
|
# No confirmed holder → must not assert one is running.
|
|
assert "already in progress" not in output
|
|
assert "No changes from compression" not in output
|
|
# Signal should be cleared after use.
|
|
assert shell.agent._compression_skipped_due_to_lock is None
|
|
|
|
|
|
def test_manual_compress_shows_lock_in_progress_with_holder(capsys):
|
|
"""When the lock holder is a descriptive string, include it in the
|
|
status message so the user knows which process to investigate."""
|
|
shell = _make_cli()
|
|
history = _make_history()
|
|
shell.conversation_history = history
|
|
shell.agent = MagicMock()
|
|
shell.agent.compression_enabled = True
|
|
shell.agent._cached_system_prompt = ""
|
|
shell.agent.tools = None
|
|
shell.agent.session_id = shell.session_id
|
|
|
|
def _fake_compress(*args, **kwargs):
|
|
shell.agent._compression_skipped_due_to_lock = "pid=12345"
|
|
return (list(history), "")
|
|
|
|
shell.agent._compress_context.side_effect = _fake_compress
|
|
|
|
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
|
shell._manual_compress()
|
|
|
|
output = capsys.readouterr().out
|
|
assert "Compression already in progress" in output
|
|
assert "pid=12345" in output
|
|
assert "No changes from compression" not in output
|