Files
EvoScientist/tests/test_compact_command.py
T
dinos bd307f3a11 refactor: LangGraph gateway layer for UI-agnostic graph and thread access (#295)
* feat(gateway): graph gateway protocol

* refactor(cli): wire gateway in cli/tui

* refactor(gateway): centralize runtime gateway init

* chore(gateway): restrict RunRequest message type

* feat(gateway): add langgraph server gateway

* chore(cli): tighten serve runtime state typing

* refactor(cli): route async task state reads through graph gateway

* refactor(gateway): support graph targets in server gateway

* refactor(cli): route session commands through graph gateway

* refactor(cli): fold thread store under graph gateway

* refactor(gateway): route graph state access through gateway

* refactor(channels): wire graph gateway

* refactor(memory): preserve graph threads for cloning

* feat(gateway): add thread cloning

* fix(tui): pass effective workspace for thread creation

* chore(memory): add workspare dir to memory worker metadata

* fix(sessions): filter preloaded UUID registy entries by the current scope

* test(fakes): use https

* refactor(consumer): consolidate imports

* fix(stream): optional summarization event

* fix(gateway): resolve abbreviated thread IDs by search

* fix(gateway): page server thread listings

* fix(gateway): emit pending interrupt events

* style: fmt

* feat(gateway): persist workspace_dir & model in thread metadata

* fix(gateway): page server thread prefix resolution

* fix(gateway): expose server thread list metadata

* refactor: add back type def

* refactor: tighten types

* revert: add back worker thread deletion

The worker thread forking changes are out of scope for now, so to
maintain parity with the existing behavior we'll leave this intact.

* fix(gateway): apply compaction to server thread history

* refactor(stream): restore direct summary replay suppression

* fix(gateway): preserve compaction state and server stream output

* fix(gateway): close local stream generator on cancellation

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-06-22 13:54:14 +00:00

466 lines
17 KiB
Python

"""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.conftest import run_async as _run
from tests.fakes import FakeCommandUI, FakeGraphGateway
_TARGET = GraphTarget()
def _compact(
graph_gateway: FakeGraphGateway,
*,
thread_id: str = "tid-1",
input_tokens_hint: int | None = None,
):
from EvoScientist.cli.commands import compact_conversation
return _run(
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."""
def test_empty_messages(self):
graph_gateway = FakeGraphGateway(state_values={"messages": []})
result = _compact(graph_gateway)
assert result.status == "noop"
assert "no messages" in result.message
def test_state_read_failure(self):
graph_gateway = FakeGraphGateway(state_error=RuntimeError("DB gone"))
result = _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."""
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 = _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."""
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 = _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()
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 = _compact(graph_gateway)
assert result.status == "ok"
assert len(graph_gateway.updated_states) == 1
class TestCompactSuccess:
"""Normal compaction flow."""
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 = _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()
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 = _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
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 = _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."""
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",
),
):
_run(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"