"""Tests for the /compact command (compact_conversation helper).""" from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from EvoScientist.gateway import GraphTarget from tests.fakes import FakeCommandUI, FakeGraphGateway _TARGET = GraphTarget() async def _compact( graph_gateway: FakeGraphGateway, *, thread_id: str = "tid-1", input_tokens_hint: int | None = None, ): from EvoScientist.cli.commands import compact_conversation return await compact_conversation( graph_gateway=graph_gateway, thread_id=thread_id, target=_TARGET, input_tokens_hint=input_tokens_hint, ) class TestCompactGuards: """Guard conditions that return early without touching the middleware.""" async def test_empty_messages(self): graph_gateway = FakeGraphGateway(state_values={"messages": []}) result = await _compact(graph_gateway) assert result.status == "noop" assert "no messages" in result.message async def test_state_read_failure(self): graph_gateway = FakeGraphGateway(state_error=RuntimeError("DB gone")) result = await _compact(graph_gateway) assert result.status == "error" assert "Failed to read state" in result.message class TestCompactCutoffZero: """When cutoff == 0, conversation is within retention budget.""" async def test_nothing_to_compact_short_conversation(self): msgs = [MagicMock() for _ in range(3)] graph_gateway = FakeGraphGateway(state_values={"messages": msgs}) mock_middleware_inst = MagicMock() mock_middleware_inst._apply_event_to_messages.return_value = msgs mock_middleware_inst._determine_cutoff_index.return_value = 0 mock_middleware_cls = MagicMock(return_value=mock_middleware_inst) model = SimpleNamespace(profile={"max_input_tokens": 1000}) with ( patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=model), patch( "EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock(), ), patch( "deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls, ), patch( "deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}, ), patch( "langchain_core.messages.utils.count_tokens_approximately", return_value=500, ), ): result = await _compact(graph_gateway) assert result.status == "noop" assert "within the retention budget" in result.message assert result.tokens_before == 500 class TestCompactNegligibleSavings: """When cutoff > 0 but savings are too small to be worth it.""" async def test_skip_when_few_messages_and_low_tokens(self): msgs = [MagicMock() for _ in range(15)] graph_gateway = FakeGraphGateway( state_values={"messages": msgs, "_summarization_event": None} ) mock_middleware_inst = MagicMock() mock_middleware_inst._apply_event_to_messages.return_value = msgs mock_middleware_inst._determine_cutoff_index.return_value = 1 # 1 message to summarize (200 tokens), 14 to keep (22000 tokens) mock_middleware_inst._partition_messages.return_value = (msgs[:1], msgs[1:]) mock_middleware_cls = MagicMock(return_value=mock_middleware_inst) model = SimpleNamespace(profile={"max_input_tokens": 50_000}) # effective=22200 (44%), to_summarize=200, to_keep=22000 → 200/22200 < 2% token_values = iter([22_200, 200, 22_000]) with ( patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=model), patch( "EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock(), ), patch( "deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls, ), patch( "deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}, ), patch( "langchain_core.messages.utils.count_tokens_approximately", side_effect=lambda x: next(token_values), ), ): result = await _compact(graph_gateway) assert result.status == "noop" assert "not worth" in result.message # No LLM call should have been made mock_middleware_inst._acreate_summary.assert_not_called() async def test_still_compacts_when_few_messages_but_high_tokens(self): """2 messages but they account for >2% of tokens — should compact.""" from langchain_core.messages import HumanMessage msgs = [MagicMock() for _ in range(10)] graph_gateway = FakeGraphGateway( state_values={"messages": msgs, "_summarization_event": None} ) summary_msg = HumanMessage(content="Summary") mock_middleware_inst = MagicMock() mock_middleware_inst._apply_event_to_messages.return_value = msgs mock_middleware_inst._determine_cutoff_index.return_value = 2 mock_middleware_inst._partition_messages.return_value = (msgs[:2], msgs[2:]) mock_middleware_inst._acreate_summary = AsyncMock(return_value="Summary") mock_middleware_inst._aoffload_to_backend = AsyncMock(return_value=None) mock_middleware_inst._build_new_messages_with_path.return_value = [summary_msg] mock_middleware_inst._compute_state_cutoff.return_value = 2 mock_middleware_cls = MagicMock(return_value=mock_middleware_inst) model = SimpleNamespace(profile={"max_input_tokens": 40_000}) # effective=20000 (50%), to_summarize=5000, to_keep=15000 → 25% > 2% token_values = iter([20_000, 5_000, 15_000, 500]) with ( patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=model), patch( "EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock(), ), patch( "deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls, ), patch( "deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}, ), patch( "langchain_core.messages.utils.count_tokens_approximately", side_effect=lambda x: next(token_values), ), ): result = await _compact(graph_gateway) assert result.status == "ok" assert len(graph_gateway.updated_states) == 1 class TestCompactSuccess: """Normal compaction flow.""" async def test_manual_threshold_blocks_low_context_compaction(self): msgs = [MagicMock() for _ in range(20)] graph_gateway = FakeGraphGateway( state_values={"messages": msgs, "_summarization_event": None} ) mock_middleware_inst = MagicMock() mock_middleware_inst._apply_event_to_messages.return_value = msgs mock_middleware_cls = MagicMock(return_value=mock_middleware_inst) model = SimpleNamespace(profile={"max_input_tokens": 100_000}) with ( patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=model), patch( "EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock(), ), patch( "deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls, ), patch( "deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}, ), patch( "langchain_core.messages.utils.count_tokens_approximately", return_value=30_000, ), ): result = await _compact(graph_gateway) assert result.status == "noop" assert "40%" in result.message assert result.context_percent == 30 mock_middleware_inst._determine_cutoff_index.assert_not_called() mock_middleware_inst._acreate_summary.assert_not_called() async def test_successful_compaction(self): from langchain_core.messages import HumanMessage msgs = [MagicMock() for _ in range(20)] graph_gateway = FakeGraphGateway( state_values={"messages": msgs, "_summarization_event": None} ) summary_msg = HumanMessage(content="Summary of conversation") to_summarize = msgs[:15] to_keep = msgs[15:] mock_middleware_inst = MagicMock() mock_middleware_inst._apply_event_to_messages.return_value = msgs mock_middleware_inst._determine_cutoff_index.return_value = 15 mock_middleware_inst._partition_messages.return_value = (to_summarize, to_keep) mock_middleware_inst._acreate_summary = AsyncMock(return_value="Summary text") mock_middleware_inst._aoffload_to_backend = AsyncMock( return_value="/conversation_history/tid.md" ) mock_middleware_inst._build_new_messages_with_path.return_value = [summary_msg] mock_middleware_inst._compute_state_cutoff.return_value = 15 mock_middleware_cls = MagicMock(return_value=mock_middleware_inst) model = SimpleNamespace(profile={"max_input_tokens": 10_000}) # effective=6000 (60%), then summarize/keep/summary accounting token_values = iter([6000, 5000, 1000, 200]) with ( patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=model), patch( "EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock(), ), patch( "deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls, ), patch( "deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}, ), patch( "langchain_core.messages.utils.count_tokens_approximately", side_effect=lambda x: next(token_values), ), ): result = await _compact(graph_gateway) assert result.status == "ok" assert result.messages_compacted == 15 assert result.messages_kept == 5 assert result.tokens_before == 6000 assert result.tokens_after == 1200 assert result.pct_decrease == 80 # context_percent reflects usage AFTER compact (12%), not before (60%) assert result.context_percent == 12 assert result.summary_text == "Summary text" assert len(graph_gateway.updated_states) == 1 # Verify the event structure passed through the graph gateway. event_data = graph_gateway.updated_states[0][2] assert "_summarization_event" in event_data assert event_data["_summarization_event"]["cutoff_index"] == 15 async def test_offload_failure_non_fatal(self): """Offload failure should not prevent compaction.""" from langchain_core.messages import HumanMessage msgs = [MagicMock() for _ in range(10)] graph_gateway = FakeGraphGateway( state_values={"messages": msgs, "_summarization_event": None} ) summary_msg = HumanMessage(content="Summary") mock_middleware_inst = MagicMock() mock_middleware_inst._apply_event_to_messages.return_value = msgs mock_middleware_inst._determine_cutoff_index.return_value = 7 mock_middleware_inst._partition_messages.return_value = (msgs[:7], msgs[7:]) mock_middleware_inst._acreate_summary = AsyncMock(return_value="Summary") mock_middleware_inst._aoffload_to_backend = AsyncMock( side_effect=RuntimeError("write failed") ) mock_middleware_inst._build_new_messages_with_path.return_value = [summary_msg] mock_middleware_inst._compute_state_cutoff.return_value = 7 mock_middleware_cls = MagicMock(return_value=mock_middleware_inst) model = SimpleNamespace(profile={"max_input_tokens": 2_000}) with ( patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=model), patch( "EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock(), ), patch( "deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls, ), patch( "deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}, ), patch( "langchain_core.messages.utils.count_tokens_approximately", return_value=1000, ), ): result = await _compact(graph_gateway) assert result.status == "ok" assert len(graph_gateway.updated_states) == 1 # file_path should be None in the event event_data = graph_gateway.updated_states[0][2] assert event_data["_summarization_event"]["file_path"] is None class TestRenderCompactResult: """Test the Rich rendering of CompactResult.""" def test_render_noop(self): from EvoScientist.cli.commands import CompactResult, render_compact_result result = CompactResult("noop", "Nothing to compact", tokens_before=500) text = render_compact_result(result) plain = text.plain assert "Nothing to compact" in plain assert "500" in plain def test_render_noop_no_tokens(self): from EvoScientist.cli.commands import CompactResult, render_compact_result result = CompactResult( "noop", "Nothing to compact — no messages in conversation." ) text = render_compact_result(result) assert "Nothing to compact" in text.plain def test_render_error(self): from EvoScientist.cli.commands import CompactResult, render_compact_result result = CompactResult("error", "Failed to read state: DB gone") text = render_compact_result(result) assert "Failed to read state" in text.plain class TestCompactCommandUI: """TUI-specific compact progress indicator behavior.""" async def test_command_uses_tui_indicator_when_available(self): from EvoScientist.cli.commands import CompactResult from EvoScientist.commands.base import CommandContext from EvoScientist.commands.implementation.session import CompactCommand ui = FakeCommandUI() # input_tokens_hint must be set for update_status_after_compact to fire # (without it, tokens_after is message-level and the unit would be wrong) ctx = CommandContext( agent=MagicMock(), thread_id="tid-1", ui=ui, graph_gateway=FakeGraphGateway(), input_tokens_hint=5000, ) result = CompactResult( "ok", "Compacted", tokens_after=1200, summary_text="summary body", ) with ( patch( "EvoScientist.cli.commands.compact_conversation", AsyncMock(return_value=result), ), patch( "EvoScientist.cli.commands.render_compact_result", return_value="result-panel", ), patch( "EvoScientist.cli.commands.build_compact_summary_renderable", return_value="summary-panel", ), ): await CompactCommand().execute(ctx, []) assert ui.started == 1 assert ui.stopped == 1 assert ui.system_messages == [] assert ui.renderables == ["result-panel", "summary-panel"] assert ui.updated_tokens == [1200] def test_render_ok(self): from EvoScientist.cli.commands import CompactResult, render_compact_result result = CompactResult( "ok", "Compacted", messages_compacted=15, messages_kept=5, tokens_before=6000, tokens_after=1200, tokens_summarized=5000, tokens_summary=200, pct_decrease=80, context_window=10_000, context_percent=60, ) text = render_compact_result(result) plain = text.plain assert "15" in plain assert "6,000" in plain assert "1,200" in plain assert "80%" in plain assert "5 messages unchanged" in plain assert "60% used" in plain def test_build_compact_summary_renderable(self): from EvoScientist.cli.commands import ( CompactResult, build_compact_summary_renderable, ) result = CompactResult("ok", "Compacted", summary_text="Summary body") renderable = build_compact_summary_renderable(result) assert renderable is not None assert renderable.summary_text == "Summary body" def test_str_fallback(self): from EvoScientist.cli.commands import CompactResult result = CompactResult("ok", "hello world") assert str(result) == "hello world"