vibe/tests/agent_loop/test_agent_auto_compact.py
maiengineering ac8f1a09fd
v2.18.4 (#866)
Co-authored-by: Albert Jiang <aj@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Mert Unsal <mert.unsal@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-07-01 19:03:09 +02:00

468 lines
16 KiB
Python

from __future__ import annotations
import asyncio
from pathlib import Path
from typing import cast
from unittest.mock import AsyncMock, patch
import pytest
from tests.conftest import (
build_test_agent_loop,
build_test_vibe_config,
make_test_models,
)
from tests.mock.utils import mock_llm_chunk
from tests.stubs.fake_backend import FakeBackend
from vibe.core.agent_loop import CompactionFailedError
from vibe.core.config import ModelConfig
from vibe.core.types import (
AssistantEvent,
CompactEndEvent,
CompactStartEvent,
FunctionCall,
LLMMessage,
Role,
ToolCall,
UserMessageEvent,
)
def _get_auto_compact_properties(
telemetry_events: list[dict[str, object]],
) -> dict[str, object]:
auto_compact = [
event
for event in telemetry_events
if event.get("event_name") == "vibe.auto_compact_triggered"
]
assert len(auto_compact) == 1
return cast(dict[str, object], auto_compact[0]["properties"])
def _get_compaction_failed_properties(
telemetry_events: list[dict[str, object]],
) -> dict[str, object]:
failed = [
event
for event in telemetry_events
if event.get("event_name") == "vibe.compaction_failed"
]
assert len(failed) == 1
return cast(dict[str, object], failed[0]["properties"])
@pytest.mark.asyncio
async def test_auto_compact_emits_correct_events(telemetry_events: list[dict]) -> None:
backend = FakeBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.stats.context_tokens = 2
old_session_id = agent.session_id
events = [ev async for ev in agent.act("Hello")]
assert len(events) == 4
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], CompactStartEvent)
assert isinstance(events[2], CompactEndEvent)
assert isinstance(events[3], AssistantEvent)
start: CompactStartEvent = events[1]
end: CompactEndEvent = events[2]
final: AssistantEvent = events[3]
assert start.current_context_tokens == 2
assert start.threshold == 1
assert isinstance(end, CompactEndEvent)
assert final.content == "<final>"
properties = _get_auto_compact_properties(telemetry_events)
assert properties["nb_context_tokens_before"] == 2
assert properties["auto_compact_threshold"] == 1
assert properties["status"] == "success"
assert properties["session_id"] == old_session_id
assert properties["parent_session_id"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
("side_effect", "expected_exception", "match", "expected_status"),
[
pytest.param(
RuntimeError("boom"), RuntimeError, "boom", "failure", id="failure"
),
pytest.param(
asyncio.CancelledError(),
asyncio.CancelledError,
None,
"cancelled",
id="cancelled",
),
],
)
async def test_auto_compact_emits_terminal_telemetry(
side_effect: BaseException,
expected_exception: type[BaseException],
match: str | None,
expected_status: str,
telemetry_events: list[dict],
) -> None:
backend = FakeBackend([[mock_llm_chunk(content="<final>")]])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.stats.context_tokens = 2
old_session_id = agent.session_id
events = []
with patch.object(agent, "compact", AsyncMock(side_effect=side_effect)):
if match is None:
with pytest.raises(expected_exception):
async for event in agent.act("Hello"):
events.append(event)
else:
with pytest.raises(expected_exception, match=match):
async for event in agent.act("Hello"):
events.append(event)
assert len(events) == 2
assert isinstance(events[0], UserMessageEvent)
assert isinstance(events[1], CompactStartEvent)
properties = _get_auto_compact_properties(telemetry_events)
assert properties["nb_context_tokens_before"] == 2
assert properties["auto_compact_threshold"] == 1
assert properties["status"] == expected_status
assert properties["session_id"] == old_session_id
assert properties["parent_session_id"] is None
@pytest.mark.asyncio
async def test_auto_compact_observer_sees_user_msg_not_summary() -> None:
"""Observer sees the original user message and final response.
Compact internals (summary request, LLM summary) are invisible
to the observer because they happen inside silent() / reset().
"""
observed: list[tuple[Role, str | None]] = []
def observer(msg: LLMMessage) -> None:
observed.append((msg.role, msg.content))
backend = FakeBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1))
agent = build_test_agent_loop(
config=cfg, message_observer=observer, backend=backend
)
agent.stats.context_tokens = 2
[_ async for _ in agent.act("Hello")]
roles = [r for r, _ in observed]
assert roles == [Role.system, Role.user, Role.assistant]
assert observed[1][1] == "Hello"
assert observed[2][1] == "<final>"
@pytest.mark.asyncio
async def test_auto_compact_observer_does_not_see_summary_request() -> None:
"""The compact summary request and LLM response must not leak to observer."""
observed: list[tuple[Role, str | None]] = []
def observer(msg: LLMMessage) -> None:
observed.append((msg.role, msg.content))
backend = FakeBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1))
agent = build_test_agent_loop(
config=cfg, message_observer=observer, backend=backend
)
agent.stats.context_tokens = 2
[_ async for _ in agent.act("Hello")]
contents = [c for _, c in observed]
assert "<summary>" not in contents
assert all("compact" not in (c or "").lower() for c in contents)
@pytest.mark.asyncio
async def test_compact_replaces_messages_with_context() -> None:
backend = FakeBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.stats.context_tokens = 2
[_ async for _ in agent.act("Hello")]
# After compact + final response: system, compaction context, final.
assert agent.messages[0].role == Role.system
assert agent.messages[-1].role == Role.assistant
assert agent.messages[-1].content == "<final>"
class _ModelTrackingBackend(FakeBackend):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.requested_models: list[ModelConfig] = []
async def complete(self, *, model, **kwargs):
self.requested_models.append(model)
return await super().complete(model=model, **kwargs)
@pytest.mark.asyncio
async def test_compact_uses_compaction_model() -> None:
"""When compaction_model is set, compact() uses it instead of active_model."""
compaction = ModelConfig(
name="compaction-model",
provider="mistral",
alias="compaction",
auto_compact_threshold=1,
)
backend = _ModelTrackingBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
cfg = build_test_vibe_config(
models=make_test_models(auto_compact_threshold=1), compaction_model=compaction
)
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.stats.context_tokens = 2
[_ async for _ in agent.act("Hello")]
assert backend.requested_models[0].name == "compaction-model"
assert backend.requested_models[1].name != "compaction-model"
@pytest.mark.asyncio
async def test_compact_uses_active_model_when_no_compaction_model() -> None:
"""Without compaction_model, compact() falls back to the active model."""
backend = _ModelTrackingBackend([
[mock_llm_chunk(content="<summary>")],
[mock_llm_chunk(content="<final>")],
])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=1))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.stats.context_tokens = 2
[_ async for _ in agent.act("Hello")]
active = cfg.get_active_model()
assert backend.requested_models[0].name == active.name
assert backend.requested_models[1].name == active.name
@pytest.mark.asyncio
async def test_compact_appends_extra_instructions_to_prompt() -> None:
backend = FakeBackend([[mock_llm_chunk(content="<summary>")]])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=999))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.messages.append(LLMMessage(role=Role.user, content="Hello"))
agent.stats.context_tokens = 100
await agent.compact(extra_instructions="focus on auth")
compaction_prompt = backend.requests_messages[0][-1].content
assert compaction_prompt is not None
assert "## Additional Instructions" in compaction_prompt
assert "focus on auth" in compaction_prompt
@pytest.mark.asyncio
async def test_compact_uses_configured_compaction_prompt(
mock_prompts_dirs: tuple[Path, Path],
) -> None:
project_prompts, _ = mock_prompts_dirs
(project_prompts / "theorem_compact.md").write_text("Summarize theorem progress")
backend = FakeBackend([[mock_llm_chunk(content="<summary>")]])
cfg = build_test_vibe_config(
models=make_test_models(auto_compact_threshold=999),
compaction_prompt_id="theorem_compact",
)
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.messages.append(LLMMessage(role=Role.user, content="Hello"))
agent.stats.context_tokens = 100
await agent.compact()
compaction_prompt = backend.requests_messages[0][-1].content
assert compaction_prompt == "Summarize theorem progress"
@pytest.mark.asyncio
async def test_compact_without_extra_instructions_has_no_additional_section() -> None:
backend = FakeBackend([[mock_llm_chunk(content="<summary>")]])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=999))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.messages.append(LLMMessage(role=Role.user, content="Hello"))
agent.stats.context_tokens = 100
await agent.compact()
compaction_prompt = backend.requests_messages[0][-1].content
assert compaction_prompt is not None
assert "## Additional Instructions" not in compaction_prompt
@pytest.mark.asyncio
async def test_compact_raises_on_tool_call_when_flag_enabled(
telemetry_events: list[dict],
) -> None:
"""With the flag on, a compaction that returns a tool call raises."""
backend = FakeBackend([
[
mock_llm_chunk(
content="",
tool_calls=[
ToolCall(
id="t1",
index=0,
function=FunctionCall(name="bash", arguments="{}"),
)
],
)
]
])
cfg = build_test_vibe_config(
models=make_test_models(auto_compact_threshold=999),
raise_on_compaction_failure=True,
)
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.messages.append(LLMMessage(role=Role.user, content="Hello"))
agent.stats.context_tokens = 100
with pytest.raises(CompactionFailedError) as exc_info:
await agent.compact()
assert exc_info.value.reason == "tool_call"
assert _get_compaction_failed_properties(telemetry_events)["reason"] == "tool_call"
@pytest.mark.asyncio
async def test_compact_raises_on_empty_summary_when_flag_enabled(
telemetry_events: list[dict],
) -> None:
"""With the flag on, a compaction with empty content raises."""
backend = FakeBackend([[mock_llm_chunk(content=" ")]])
cfg = build_test_vibe_config(
models=make_test_models(auto_compact_threshold=999),
raise_on_compaction_failure=True,
)
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.messages.append(LLMMessage(role=Role.user, content="Hello"))
agent.stats.context_tokens = 100
with pytest.raises(CompactionFailedError) as exc_info:
await agent.compact()
assert exc_info.value.reason == "empty_summary"
assert (
_get_compaction_failed_properties(telemetry_events)["reason"] == "empty_summary"
)
@pytest.mark.asyncio
async def test_compact_falls_back_when_flag_disabled(
telemetry_events: list[dict],
) -> None:
"""With the flag off (default), empty content uses the legacy fallback.
The compaction failure telemetry event must still be sent even though the
flag is off and compact() returns normally.
"""
backend = FakeBackend([[mock_llm_chunk(content="")]])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=999))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.messages.append(LLMMessage(role=Role.user, content="Hello"))
agent.stats.context_tokens = 100
summary = await agent.compact()
assert summary == "(no summary available)"
assert (
_get_compaction_failed_properties(telemetry_events)["reason"] == "empty_summary"
)
@pytest.mark.asyncio
async def test_compact_message_shape_preserves_prior_user_messages() -> None:
from vibe.core.compaction import parse_previous_user_messages
from vibe.core.prompts import UtilityPrompt
summary_prefix = UtilityPrompt.COMPACT_SUMMARY_PREFIX.read()
backend = FakeBackend([[mock_llm_chunk(content="fresh summary body")]])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=999))
agent = build_test_agent_loop(config=cfg, backend=backend)
system_message_before = agent.messages[0]
agent.messages.append(LLMMessage(role=Role.user, content="first real ask"))
agent.messages.append(
LLMMessage(role=Role.user, content="middleware ping", injected=True)
)
agent.messages.append(LLMMessage(role=Role.assistant, content="ack"))
agent.messages.append(
LLMMessage(
role=Role.user,
content=f"{summary_prefix}\nprior summary blob",
injected=True,
)
)
agent.messages.append(LLMMessage(role=Role.user, content="follow-up ask"))
agent.stats.context_tokens = 100
await agent.compact()
final = list(agent.messages)
assert len(final) == 2 # [system, compaction_context]
assert final[0] is system_message_before
assert final[1].role == Role.user
assert final[1].injected is True
assert parse_previous_user_messages(final[1].content or "") == [
"first real ask",
"follow-up ask",
]
assert "Here are some of the most recent previous user messages" in (
final[1].content or ""
)
assert "<compaction_summary>" in (final[1].content or "")
assert "fresh summary body" in (final[1].content or "")
# Injected and prior-summary user messages must be filtered out.
assert all("middleware ping" not in (m.content or "") for m in final)
assert sum("prior summary blob" in (m.content or "") for m in final) == 0
@pytest.mark.asyncio
async def test_compact_preserves_user_messages_across_repeated_compactions() -> None:
from vibe.core.compaction import parse_previous_user_messages
backend = FakeBackend([
[mock_llm_chunk(content="summary one")],
[mock_llm_chunk(content="summary two")],
])
cfg = build_test_vibe_config(models=make_test_models(auto_compact_threshold=999))
agent = build_test_agent_loop(config=cfg, backend=backend)
agent.messages.append(LLMMessage(role=Role.user, content="first ask"))
agent.stats.context_tokens = 100
await agent.compact()
agent.messages.append(LLMMessage(role=Role.user, content="second ask"))
agent.stats.context_tokens = 100
await agent.compact()
final = list(agent.messages)
assert len(final) == 2
assert parse_previous_user_messages(final[1].content or "") == [
"first ask",
"second ask",
]