""" Sprint 46 Tests: manual session compression with optional focus topic. """ import contextlib import io import json import os import sys import threading import time import types from api.models import Session from api.config import SESSION_DIR from api.routes import _handle_session_compress, get_session from tests._pytest_port import BASE class _FakeHandler: def __init__(self): self.wfile = io.BytesIO() self.status = None self.sent_headers = {} def send_response(self, status): self.status = status def send_header(self, key, value): self.sent_headers[key] = value def end_headers(self): pass def payload(self): return json.loads(self.wfile.getvalue().decode("utf-8")) class _FakeCompressor: def __init__(self): self.calls = [] def compress(self, messages, current_tokens=None, focus_topic=None): self.calls.append( { "messages": list(messages), "current_tokens": current_tokens, "focus_topic": focus_topic, } ) if len(messages) >= 2: return [messages[0], messages[-1]] return list(messages) class _FakeAgent: last_instance = None def __init__(self, **kwargs): self.kwargs = kwargs self.context_compressor = _FakeCompressor() _FakeAgent.last_instance = self def _install_fake_compression_runtime(monkeypatch, agent_cls): fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = agent_cls monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) import api.config as _cfg fake_runtime_provider = types.ModuleType("hermes_cli.runtime_provider") fake_runtime_provider.resolve_runtime_provider = lambda requested=None: { "api_key": "fake-key", "provider": requested or "openai", "base_url": "https://api.openai.com/v1", } fake_hermes_cli = types.ModuleType("hermes_cli") fake_hermes_cli.__path__ = [] fake_hermes_cli.runtime_provider = fake_runtime_provider monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli) monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_provider) import hermes_cli.runtime_provider as _rtp monkeypatch.setattr( _cfg, "resolve_model_provider", lambda model: ("openai/gpt-5.4-mini", "openai", "https://api.openai.com/v1"), ) monkeypatch.setattr( _cfg, "_get_session_agent_lock", lambda sid: contextlib.nullcontext(), ) monkeypatch.setattr( _rtp, "resolve_runtime_provider", lambda requested=None: { "api_key": "fake-key", "provider": requested or "openai", "base_url": "https://api.openai.com/v1", }, ) def _make_session(messages=None, tool_calls=None): SESSION_DIR.mkdir(parents=True, exist_ok=True) messages = messages or [ {"role": "user", "content": "one"}, {"role": "assistant", "content": "two"}, {"role": "user", "content": "three"}, {"role": "assistant", "content": "four"}, ] s = Session( session_id=f"compress_test_{time.time_ns()}", title="Untitled", workspace="/tmp/hermes-webui-test", model="openai/gpt-5.4-mini", messages=messages, tool_calls=tool_calls or [], ) s.save(touch_updated_at=False) return s.session_id def test_session_compress_requires_session_id(cleanup_test_sessions): handler = _FakeHandler() _handle_session_compress(handler, {}) assert handler.status == 400 assert handler.payload()["error"] == "Missing required field(s): session_id" def test_session_compress_roundtrip(monkeypatch, cleanup_test_sessions): created = cleanup_test_sessions original_messages = [ {"role": "user", "content": "one"}, {"role": "assistant", "content": "two"}, {"role": "user", "content": "three"}, {"role": "assistant", "content": "four"}, ] compressed_messages = [original_messages[0], original_messages[-1]] settled_tool_calls = [ { "id": "call_1", "name": "terminal", "assistant_msg_idx": 1, "done": True, "result": "schema.sql", } ] sid = _make_session(original_messages, tool_calls=settled_tool_calls) created.append(sid) _install_fake_compression_runtime(monkeypatch, _FakeAgent) handler = _FakeHandler() _handle_session_compress(handler, {"session_id": sid, "focus_topic": "database schema"}) assert handler.status == 200 payload = handler.payload() assert payload["ok"] is True assert payload["focus_topic"] == "database schema" assert payload["summary"]["headline"] == "Compressed: 4 → 2 messages" assert payload["session"]["session_id"] == sid assert payload["session"]["messages"] == original_messages assert payload["session"]["tool_calls"] == settled_tool_calls assert payload["session"]["message_count"] == len(original_messages) assert payload["session"]["compression_anchor_summary"] is not None assert payload["session"]["compression_anchor_visible_idx"] == 3 assert isinstance(payload["session"]["compression_anchor_message_key"], dict) assert payload["session"]["compression_anchor_message_key"].get("role") == "assistant" assert payload["session"]["compression_anchor_message_key"].get("text") == "four" loaded = get_session(sid) persisted = Session.load(sid) assert loaded.messages == original_messages assert [m.get("role") for m in loaded.context_messages] == [m.get("role") for m in compressed_messages] assert [m.get("content") for m in loaded.context_messages] == [m.get("content") for m in compressed_messages] assert loaded.tool_calls == settled_tool_calls assert persisted.messages == original_messages assert [m.get("role") for m in persisted.context_messages] == [m.get("role") for m in compressed_messages] assert [m.get("content") for m in persisted.context_messages] == [m.get("content") for m in compressed_messages] assert persisted.tool_calls == settled_tool_calls assert loaded.compression_anchor_summary == payload["session"]["compression_anchor_summary"] assert loaded.compression_anchor_visible_idx == payload["session"]["compression_anchor_visible_idx"] assert loaded.compression_anchor_message_key == payload["session"]["compression_anchor_message_key"] assert loaded.compression_anchor_mode == "manual" assert loaded.truncation_watermark is not None assert loaded.truncation_boundary == loaded.truncation_watermark assert loaded.last_prompt_tokens is not None assert persisted.compression_anchor_mode == "manual" assert persisted.truncation_watermark == loaded.truncation_watermark assert persisted.truncation_boundary == loaded.truncation_boundary assert not (SESSION_DIR / f"{sid}.json.bak").exists() assert persisted.compression_anchor_visible_idx == 3 assert persisted.compression_anchor_message_key == payload["session"]["compression_anchor_message_key"] assert _FakeAgent.last_instance is not None assert _FakeAgent.last_instance.context_compressor.calls[0]["focus_topic"] == "database schema" def test_session_compress_start_is_async_and_reuses_running_job(monkeypatch, cleanup_test_sessions): import api.routes as routes assert hasattr(routes, "_handle_session_compress_start") assert hasattr(routes, "_handle_session_compress_status") class BlockingCompressor: entered = threading.Event() release = threading.Event() calls = [] def compress(self, messages, current_tokens=None, focus_topic=None): self.calls.append({"messages": list(messages), "focus_topic": focus_topic}) self.entered.set() assert self.release.wait(timeout=5), "test timed out waiting to release compression" return [messages[0], messages[-1]] class BlockingAgent: instances = [] def __init__(self, **kwargs): self.context_compressor = BlockingCompressor() self.instances.append(self) created = cleanup_test_sessions settled_tool_calls = [ { "id": "call_async_1", "name": "read_file", "assistant_msg_idx": 1, "done": True, "result": "config.yaml", } ] sid = _make_session(tool_calls=settled_tool_calls) created.append(sid) _install_fake_compression_runtime(monkeypatch, BlockingAgent) try: first = _FakeHandler() routes._handle_session_compress_start(first, {"session_id": sid, "focus_topic": "slow"}) assert first.status == 200 first_payload = first.payload() assert first_payload["ok"] is True assert first_payload["status"] == "running" assert first_payload["session_id"] == sid assert first_payload["focus_topic"] == "slow" assert BlockingCompressor.entered.wait(timeout=2) second = _FakeHandler() routes._handle_session_compress_start(second, {"session_id": sid, "focus_topic": "slow"}) assert second.status == 200 second_payload = second.payload() assert second_payload["status"] == "running" assert len(BlockingAgent.instances) == 1 running = _FakeHandler() routes._handle_session_compress_status(running, sid) assert running.status == 200 assert running.payload()["status"] == "running" finally: BlockingCompressor.release.set() deadline = time.time() + 5 done_payload = None while time.time() < deadline: done = _FakeHandler() routes._handle_session_compress_status(done, sid) payload = done.payload() if payload["status"] == "done": done_payload = payload break time.sleep(0.02) assert done_payload is not None assert done_payload["summary"]["headline"] == "Compressed: 4 → 2 messages" assert done_payload["session"]["messages"] == [ {"role": "user", "content": "one"}, {"role": "assistant", "content": "two"}, {"role": "user", "content": "three"}, {"role": "assistant", "content": "four"}, ] assert done_payload["session"]["tool_calls"] == settled_tool_calls persisted = Session.load(sid) assert persisted.tool_calls == settled_tool_calls # /compress now stamps missing message timestamps, so compare role/content # rather than full-dict equality (mirrors test_session_compress_roundtrip). assert [m.get("role") for m in persisted.context_messages] == ["user", "assistant"] assert [m.get("content") for m in persisted.context_messages] == ["one", "four"] def test_session_compress_status_reports_worker_error_without_raw_paths(monkeypatch, cleanup_test_sessions): import api.routes as routes assert hasattr(routes, "_handle_session_compress_start") assert hasattr(routes, "_handle_session_compress_status") class FailingCompressor: entered = threading.Event() def compress(self, messages, current_tokens=None, focus_topic=None): self.entered.set() raise RuntimeError("provider log at /Users/alice/.hermes/secrets/token.txt failed") class FailingAgent: def __init__(self, **kwargs): self.context_compressor = FailingCompressor() created = cleanup_test_sessions sid = _make_session() created.append(sid) _install_fake_compression_runtime(monkeypatch, FailingAgent) start = _FakeHandler() routes._handle_session_compress_start(start, {"session_id": sid}) assert start.status == 200 assert FailingCompressor.entered.wait(timeout=2) deadline = time.time() + 5 error_payload = None while time.time() < deadline: status = _FakeHandler() routes._handle_session_compress_status(status, sid) payload = status.payload() if payload["status"] == "error": error_payload = payload break time.sleep(0.02) assert error_payload is not None assert error_payload["ok"] is False assert error_payload["error_status"] == 400 assert "" in error_payload["error"] assert "/Users/alice" not in error_payload["error"] def test_session_compress_start_retries_after_terminal_error(monkeypatch, cleanup_test_sessions): import api.routes as routes class BlockingCompressor: entered = threading.Event() release = threading.Event() def compress(self, messages, current_tokens=None, focus_topic=None): self.entered.set() assert self.release.wait(timeout=5), "test timed out waiting to release compression" return [messages[0], messages[-1]] class BlockingAgent: def __init__(self, **kwargs): self.context_compressor = BlockingCompressor() created = cleanup_test_sessions sid = _make_session() created.append(sid) _install_fake_compression_runtime(monkeypatch, BlockingAgent) with routes._MANUAL_COMPRESSION_JOBS_LOCK: routes._MANUAL_COMPRESSION_JOBS[sid] = { "session_id": sid, "focus_topic": None, "status": "error", "error": "previous failure", "error_status": 400, "started_at": time.time(), "updated_at": time.time(), } try: retry = _FakeHandler() routes._handle_session_compress_start(retry, {"session_id": sid}) assert retry.status == 200 retry_payload = retry.payload() assert retry_payload["status"] == "running" assert retry_payload["ok"] is True assert BlockingCompressor.entered.wait(timeout=2) finally: BlockingCompressor.release.set() def test_session_compress_async_reports_stale_session_guard(monkeypatch, cleanup_test_sessions): import api.routes as routes created = cleanup_test_sessions sid = _make_session() created.append(sid) class MutatingCompressor: entered = threading.Event() def compress(self, messages, current_tokens=None, focus_topic=None): live = get_session(sid) live.messages.append({"role": "user", "content": "concurrent edit"}) self.entered.set() return [messages[0], messages[-1]] class MutatingAgent: def __init__(self, **kwargs): self.context_compressor = MutatingCompressor() _install_fake_compression_runtime(monkeypatch, MutatingAgent) start = _FakeHandler() routes._handle_session_compress_start(start, {"session_id": sid}) assert start.status == 200 assert MutatingCompressor.entered.wait(timeout=2) deadline = time.time() + 5 error_payload = None while time.time() < deadline: status = _FakeHandler() routes._handle_session_compress_status(status, sid) payload = status.payload() if payload["status"] == "error": error_payload = payload break time.sleep(0.02) assert error_payload is not None assert error_payload["ok"] is False assert error_payload["error_status"] == 409 assert "modified during compression" in error_payload["error"] assert get_session(sid).messages[-1]["content"] == "concurrent edit" def test_session_compress_async_reports_stream_state_guard(monkeypatch, cleanup_test_sessions): import api.routes as routes created = cleanup_test_sessions sid = _make_session() created.append(sid) class StreamMutatingCompressor: entered = threading.Event() def compress(self, messages, current_tokens=None, focus_topic=None): live = get_session(sid) live.active_stream_id = "stream-concurrent" self.entered.set() return [messages[0], messages[-1]] class StreamMutatingAgent: def __init__(self, **kwargs): self.context_compressor = StreamMutatingCompressor() _install_fake_compression_runtime(monkeypatch, StreamMutatingAgent) start = _FakeHandler() routes._handle_session_compress_start(start, {"session_id": sid}) assert start.status == 200 assert StreamMutatingCompressor.entered.wait(timeout=2) deadline = time.time() + 5 error_payload = None while time.time() < deadline: status = _FakeHandler() routes._handle_session_compress_status(status, sid) payload = status.payload() if payload["status"] == "error": error_payload = payload break time.sleep(0.02) assert error_payload is not None assert error_payload["ok"] is False assert error_payload["error_status"] == 409 assert "stream state changed" in error_payload["error"] assert get_session(sid).active_stream_id == "stream-concurrent" def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, cleanup_test_sessions): import api.profiles as profiles import api.routes as routes class EnvAssertingAgent: seen_env = None def __init__(self, **kwargs): from api.config import _thread_ctx skill_module = sys.modules.get("tools.skills_tool") thread_env = getattr(_thread_ctx, "env", {}) EnvAssertingAgent.seen_env = { "HERMES_HOME": os.environ.get("HERMES_HOME"), "HERMES_TEST_PROFILE_ENV": os.environ.get("HERMES_TEST_PROFILE_ENV"), "THREAD_HERMES_HOME": thread_env.get("HERMES_HOME"), "THREAD_HERMES_TEST_PROFILE_ENV": thread_env.get("HERMES_TEST_PROFILE_ENV"), "SKILL_MODULE_HOME": getattr(skill_module, "HERMES_HOME", None), "SKILL_MODULE_DIR": getattr(skill_module, "SKILLS_DIR", None), } self.context_compressor = _FakeCompressor() created = cleanup_test_sessions sid = _make_session() created.append(sid) session = get_session(sid) session.profile = "work" session.model_provider = "profile-provider" session.save(touch_updated_at=False) profile_home = tmp_path / "work-profile-home" fake_skill_module = types.ModuleType("tools.skills_tool") setattr(fake_skill_module, "HERMES_HOME", "default-home") setattr(fake_skill_module, "SKILLS_DIR", "default-home/skills") monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home) monkeypatch.setattr( profiles, "get_profile_runtime_env", lambda home: {"HERMES_TEST_PROFILE_ENV": "work-runtime"}, ) monkeypatch.setenv("HERMES_HOME", "default-home") monkeypatch.delenv("HERMES_TEST_PROFILE_ENV", raising=False) _install_fake_compression_runtime(monkeypatch, EnvAssertingAgent) with routes._MANUAL_COMPRESSION_JOBS_LOCK: routes._MANUAL_COMPRESSION_JOBS[sid] = { "session_id": sid, "focus_topic": None, "status": "running", "started_at": time.time(), "updated_at": time.time(), } routes._run_manual_compression_job(sid, {"session_id": sid}) assert EnvAssertingAgent.seen_env == { "HERMES_HOME": str(profile_home), "HERMES_TEST_PROFILE_ENV": "work-runtime", "THREAD_HERMES_HOME": str(profile_home), "THREAD_HERMES_TEST_PROFILE_ENV": "work-runtime", "SKILL_MODULE_HOME": profile_home, "SKILL_MODULE_DIR": profile_home / "skills", } assert str(getattr(fake_skill_module, "HERMES_HOME")) == "default-home" assert str(getattr(fake_skill_module, "SKILLS_DIR")) == "default-home/skills" assert os.environ.get("HERMES_HOME") == "default-home" assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None with routes._MANUAL_COMPRESSION_JOBS_LOCK: assert routes._MANUAL_COMPRESSION_JOBS[sid]["status"] == "done" def test_static_commands_js_registers_compress_alias(cleanup_test_sessions): from pathlib import Path with open(Path(__file__).resolve().parents[1] / "static" / "commands.js", encoding="utf-8") as f: src = f.read() assert "name:'compress'" in src assert "name:'compact'" in src assert "/api/session/compress/start" in src assert "/api/session/compress/status" in src assert "await api('/api/session/compress'," not in src assert "beforeCount:visibleCount" in src assert "cmdCompress" in src assert "cmdCompact" in src def test_static_commands_js_prefers_persisted_reference_message(cleanup_test_sessions): from pathlib import Path with open(Path(__file__).resolve().parents[1] / "static" / "commands.js", encoding="utf-8") as f: src = f.read() assert "const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';" in src assert "const referenceText=messageRef || summaryRef;" in src def test_static_session_load_resumes_manual_compression_polling(cleanup_test_sessions): from pathlib import Path with open(Path(__file__).resolve().parents[1] / "static" / "sessions.js", encoding="utf-8") as f: src = f.read() assert "resumeManualCompressionForSession" in src