Files
2026-07-13 13:12:33 +08:00

1467 lines
52 KiB
Python

"""Tests for pre-flight compaction (Feature D).
Covers:
- Pre-flight triggers when transcript exceeds the configured context-window ratio
- Pre-flight does NOT trigger when under threshold
- cron: and subagent: sessions are skipped
- Missing sessions don't error
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from opensquilla.engine import runtime as runtime_module
from opensquilla.engine.runtime import TurnRunner
from opensquilla.gateway.config import GatewayConfig
from opensquilla.provider import DoneEvent as ProviderDone
from opensquilla.provider import Message, ModelInfo
from opensquilla.provider import TextDeltaEvent as ProviderText
from opensquilla.provider.model_catalog import ModelCatalog
from opensquilla.session.compaction import CompactionConfig
from opensquilla.session.manager import SessionManager
from opensquilla.session.models import TranscriptEntry
from opensquilla.session.storage import SessionStorage
from opensquilla.tools.types import CallerKind, ToolContext
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_entry(content: str, role: str = "user") -> TranscriptEntry:
return TranscriptEntry(
session_id="test-session-id",
session_key="test:key",
role=role,
content=content,
)
def _flush_enabled_config(**overrides: Any) -> SimpleNamespace:
memory = {
"flush_enabled": True,
"flush_pre_compaction": True,
"flush_timeout_seconds": 0.25,
"flush_background_timeout_seconds": 120.0,
"flush_compaction_requires_safe_receipt": False,
}
memory.update(overrides)
return SimpleNamespace(memory=SimpleNamespace(**memory))
def _make_assistant_tool_entry(content: str, tool_calls: list[dict[str, Any]]) -> TranscriptEntry:
return TranscriptEntry(
session_id="test-session-id",
session_key="test:key",
role="assistant",
content=content,
tool_calls=tool_calls,
token_count=1,
)
def _make_assistant_reasoning_entry(content: str, reasoning_content: str) -> TranscriptEntry:
return TranscriptEntry(
session_id="test-session-id",
session_key="test:key",
role="assistant",
content=content,
reasoning_content=reasoning_content,
token_count=1,
)
def _checkpoint_receipt() -> SimpleNamespace:
return SimpleNamespace(
scope="checkpoint",
status="checkpoint_saved",
source_path="memory/.checkpoints/s/turn.jsonl",
content_hash="h1",
)
def _flush_receipt(**overrides):
payload = {
"mode": "llm",
"error": None,
"indexed_chunk_count": 1,
"integrity_status": "ok",
"output_coverage_status": "ok",
"invalid_candidate_count": 0,
"candidate_missing_ids": [],
"obligation_status": "ok",
"obligation_missing_ids": [],
}
payload.update(overrides)
return SimpleNamespace(**payload)
class _FakeCompactionProvider:
provider_name = "openai"
def __init__(self, model: str = "provider/model") -> None:
self._api_key = "preflight-provider-key"
self._model = model
self._base_url = "https://openrouter.ai/api/v1"
@property
def model(self) -> str:
return self._model
def chat(self, messages: list[Message], tools=None, config=None) -> AsyncIterator:
return self._stream()
async def _stream(self) -> AsyncIterator:
yield ProviderText(text="ok")
yield ProviderDone(stop_reason="end_turn", input_tokens=1, output_tokens=1)
async def list_models(self) -> list[ModelInfo]:
return []
class _FakeSelectorClone:
current_config = SimpleNamespace(model="provider/model")
def __init__(self, provider: _FakeCompactionProvider) -> None:
self.provider = provider
self.override_calls: list[str] = []
def override_model(self, model: str) -> None:
self.override_calls.append(model)
self.provider._model = model
self.current_config = SimpleNamespace(model=model)
def resolve(self) -> _FakeCompactionProvider:
return self.provider
class _FakeProviderSelector:
current_config = SimpleNamespace(model="provider/model")
def __init__(self, provider: _FakeCompactionProvider | None = None) -> None:
self.provider = provider or _FakeCompactionProvider()
self.clone_instance = _FakeSelectorClone(self.provider)
self.override_calls: list[str] = []
def clone(self) -> _FakeSelectorClone:
return self.clone_instance
def override_model(self, model: str) -> None:
self.override_calls.append(model)
def resolve(self) -> _FakeCompactionProvider:
return self.provider
class _ResultCompactionSessionManager:
def __init__(self, transcript: list[TranscriptEntry]) -> None:
self._transcript = transcript
self.compact_with_result_calls: list[tuple[str, int, object | None]] = []
self.compact_with_result_kwargs: list[dict[str, object | None]] = []
async def get_transcript(self, session_key: str) -> list[TranscriptEntry]:
return list(self._transcript)
async def compact_with_result(
self,
session_key: str,
context_window_tokens: int,
config: object | None = None,
**kwargs,
) -> SimpleNamespace:
self.compact_with_result_calls.append((session_key, context_window_tokens, config))
self.compact_with_result_kwargs.append(dict(kwargs))
return SimpleNamespace(
summary="summary text",
kept_entries=[{"role": "assistant", "content": "tail"}],
removed_count=4,
chunks_processed=3,
summary_source="llm",
tokens_before=1000,
tokens_after=200,
remaining_budget_tokens=800,
)
class _FailingResultCompactionSessionManager(_ResultCompactionSessionManager):
async def compact_with_result(
self,
session_key: str,
context_window_tokens: int,
config: object | None = None,
**kwargs,
) -> SimpleNamespace:
self.compact_with_result_calls.append((session_key, context_window_tokens, config))
self.compact_with_result_kwargs.append(dict(kwargs))
raise RuntimeError("preimage write failed")
class _StaleResultCompactionSessionManager(_ResultCompactionSessionManager):
async def compact_with_result(
self,
session_key: str,
context_window_tokens: int,
config: object | None = None,
**kwargs,
) -> SimpleNamespace:
self.compact_with_result_calls.append((session_key, context_window_tokens, config))
self.compact_with_result_kwargs.append(dict(kwargs))
return SimpleNamespace(
summary="",
kept_entries=list(self._transcript),
removed_count=0,
chunks_processed=0,
summary_source="skipped",
skip_reason="stale_preimage",
tokens_before=2400,
tokens_after=2400,
remaining_budget_tokens=0,
)
@pytest.fixture
async def session_mgr(tmp_path):
storage = SessionStorage(":memory:")
await storage.connect()
mgr = SessionManager(storage, checkpoint_workspace_dir=tmp_path)
yield mgr
await storage.close()
# ---------------------------------------------------------------------------
# Tests: _maybe_preflight_compact
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_preflight_no_session_manager_is_noop() -> None:
"""When session_manager is None, pre-flight silently returns."""
runner = TurnRunner(provider_selector=MagicMock(), session_manager=None)
# Should not raise
await runner._maybe_preflight_compact("some:session", 200_000)
@pytest.mark.asyncio
async def test_preflight_skips_cron_sessions() -> None:
"""cron: prefixed sessions are skipped regardless of token count."""
mock_sm = MagicMock()
mock_sm.compact = AsyncMock()
mock_sm.get_transcript = AsyncMock()
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
await runner._maybe_preflight_compact("cron:daily-job", 200_000)
mock_sm.get_transcript.assert_not_called()
mock_sm.compact.assert_not_called()
@pytest.mark.asyncio
async def test_preflight_skips_subagent_sessions() -> None:
"""subagent: prefixed sessions are skipped regardless of token count."""
mock_sm = MagicMock()
mock_sm.compact = AsyncMock()
mock_sm.get_transcript = AsyncMock()
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
await runner._maybe_preflight_compact("subagent:worker-1", 200_000)
mock_sm.get_transcript.assert_not_called()
mock_sm.compact.assert_not_called()
@pytest.mark.asyncio
async def test_preflight_missing_session_does_not_error() -> None:
"""KeyError from get_transcript (session doesn't exist) is swallowed."""
mock_sm = MagicMock()
mock_sm.compact = AsyncMock()
mock_sm.get_transcript = AsyncMock(side_effect=KeyError("Session not found"))
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
# Should not raise
await runner._maybe_preflight_compact("missing:session", 200_000)
mock_sm.compact.assert_not_called()
@pytest.mark.asyncio
async def test_preflight_empty_transcript_is_noop() -> None:
"""Empty transcript skips compaction."""
mock_sm = MagicMock()
mock_sm.compact = AsyncMock()
mock_sm.get_transcript = AsyncMock(return_value=[])
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
await runner._maybe_preflight_compact("user:session", 200_000)
mock_sm.compact.assert_not_called()
@pytest.mark.asyncio
async def test_preflight_under_threshold_does_not_compact() -> None:
"""Transcript under the configured context-window ratio → no compaction."""
# 100 tokens worth of content (estimate_tokens("x" * 400) ≈ 100 with len//4 fallback)
entries = [_make_entry("x" * 400)] # ~100 tokens
mock_sm = MagicMock()
mock_sm.compact = AsyncMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
# Threshold = 200_000 * 0.85 = 170_000 — 100 tokens is well under
await runner._maybe_preflight_compact("user:session", 200_000)
mock_sm.compact.assert_not_called()
@pytest.mark.asyncio
async def test_preflight_uses_configured_compaction_ratio() -> None:
"""Operators can tune the preflight threshold without code changes."""
context_window = 1000
entries = [_make_entry("a")]
mock_sm = MagicMock()
mock_sm.compact = AsyncMock(return_value="summary text")
mock_sm.get_transcript = AsyncMock(return_value=entries)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
config=SimpleNamespace(preflight_compact_ratio=0.5),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=600):
await runner._maybe_preflight_compact("user:session", context_window)
mock_sm.compact.assert_called_once_with("user:session", context_window)
@pytest.mark.asyncio
async def test_preflight_above_threshold_triggers_compact() -> None:
"""Transcript exceeding the default 85% context-window ratio triggers compaction."""
# Use patch to control estimate_tokens so threshold math is deterministic
context_window = 1000
# Create entries whose total estimated tokens exceed threshold
entries = [_make_entry("a" * 4000)] # len//4 = 1000 tokens > 850
mock_sm = MagicMock()
mock_sm.compact = AsyncMock(return_value="summary text")
mock_sm.get_transcript = AsyncMock(return_value=entries)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("user:session", context_window)
mock_sm.compact.assert_called_once_with("user:session", context_window)
@pytest.mark.asyncio
async def test_preflight_checkpoint_runs_before_compact() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
calls: list[str] = []
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(
side_effect=lambda *args, **kwargs: calls.append("compact") or "summary"
)
mock_sm.record_memory_checkpoint = AsyncMock(
side_effect=lambda *args, **kwargs: calls.append("checkpoint")
or _checkpoint_receipt()
)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
assert calls[0] == "checkpoint"
assert "compact" in calls
@pytest.mark.asyncio
async def test_preflight_compacts_when_distill_fails_after_checkpoint() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
calls: list[str] = []
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.record_memory_checkpoint = AsyncMock(
side_effect=lambda *args, **kwargs: calls.append("checkpoint")
or _checkpoint_receipt()
)
mock_sm.compact = AsyncMock(
side_effect=lambda *args, **kwargs: calls.append("compact") or "summary text"
)
async def _flush_fails(*args: Any, **kwargs: Any) -> SimpleNamespace:
calls.append("flush")
raise RuntimeError("bad json")
flush_service = MagicMock()
flush_service.execute = AsyncMock(side_effect=_flush_fails)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(
flush_enabled=True,
flush_pre_compaction=True,
flush_timeout_seconds=0.25,
flush_background_timeout_seconds=42.0,
)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
assert calls[:2] == ["checkpoint", "compact"]
await asyncio.sleep(0)
assert "flush" in calls
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
@pytest.mark.asyncio
async def test_preflight_checkpoint_failure_prevents_destructive_compaction() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.record_memory_checkpoint = AsyncMock(
side_effect=RuntimeError("checkpoint write failed")
)
mock_sm.compact = AsyncMock(return_value="summary text")
flush_service = MagicMock()
flush_service.execute = AsyncMock(return_value=_flush_receipt())
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(
flush_enabled=True,
flush_pre_compaction=True,
flush_timeout_seconds=0.25,
flush_background_timeout_seconds=42.0,
)
),
)
with (
patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000),
pytest.raises(RuntimeError, match="checkpoint write failed"),
):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
flush_service.execute.assert_not_called()
mock_sm.compact.assert_not_called()
@pytest.mark.asyncio
async def test_preflight_completed_event_reports_compaction_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
context_window = 1000
entries = [_make_entry("a" * 4000)]
sm = _ResultCompactionSessionManager(entries)
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
runtime_module,
"notify_compaction",
lambda session_key, **payload: events.append((session_key, payload)),
)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=sm)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("user:session", context_window)
assert sm.compact_with_result_calls == [("user:session", context_window, None)]
assert [(key, payload["status"]) for key, payload in events] == [
("user:session", "started"),
("user:session", "observed"),
("user:session", "observed"),
("user:session", "completed"),
]
compaction_ids = {payload.get("compaction_id") for _, payload in events}
assert len(compaction_ids) == 1
assert None not in compaction_ids
assert events[0][1]["event"] == "compaction.triggered"
assert events[1][1]["event"] == "compaction.chunk_summarized"
assert events[2][1]["event"] == "compaction.summary_verified"
completed = events[-1][1]
assert completed["applied"] is True
assert completed["durability"] == "durable"
assert completed["user_visible"] is True
assert completed["event"] == "compaction.persisted"
assert completed["event_chain"] == [
"compaction.triggered",
"compaction.chunk_summarized",
"compaction.summary_verified",
"compaction.persisted",
]
assert completed["coverage_status"] == "unknown"
assert completed["chunk_count"] == 3
assert completed["summary_source"] == "llm"
assert completed["removed_count"] == 4
assert completed["kept_count"] == 1
assert completed["tokens_after"] == 200
assert completed["remaining_budget_tokens"] == 800
@pytest.mark.asyncio
async def test_preflight_counts_tool_call_arguments_when_deciding_to_compact() -> None:
context_window = 1000
entries = [
_make_assistant_tool_entry(
"small visible answer",
[
{
"type": "tool_use",
"tool_use_id": "write-stale",
"name": "write_file",
"input": {
"path": "index.html",
"content": "x" * 5000,
},
}
],
)
]
mock_sm = MagicMock()
mock_sm.compact = AsyncMock(return_value="summary text")
mock_sm.get_transcript = AsyncMock(return_value=entries)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
with patch(
"opensquilla.session.tokenizer.estimate_tokens",
side_effect=lambda text: max(1, len(str(text)) // 4),
):
await runner._maybe_preflight_compact("user:session", context_window)
mock_sm.compact.assert_called_once_with("user:session", context_window)
@pytest.mark.asyncio
async def test_preflight_counts_reasoning_content_when_deciding_to_compact() -> None:
context_window = 1000
entries = [
_make_assistant_reasoning_entry(
"small visible answer",
"reasoning " + ("r" * 5000),
)
]
mock_sm = MagicMock()
mock_sm.compact = AsyncMock(return_value="summary text")
mock_sm.get_transcript = AsyncMock(return_value=entries)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
with patch(
"opensquilla.session.tokenizer.estimate_tokens",
side_effect=lambda text: max(1, len(str(text)) // 4),
):
await runner._maybe_preflight_compact("user:session", context_window)
mock_sm.compact.assert_called_once_with("user:session", context_window)
@pytest.mark.asyncio
async def test_preflight_starts_full_transcript_flush_without_blocking_compact() -> None:
"""Preflight starts full-coverage memory flush in the background."""
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
calls: list[str] = []
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
async def compact(session_key, context_window_tokens):
calls.append("compact")
return "summary text"
mock_sm.compact = AsyncMock(side_effect=compact)
flush_service = MagicMock()
async def flush_execute(*args, **kwargs):
calls.append("flush")
return _flush_receipt()
flush_service.execute = AsyncMock(side_effect=flush_execute)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=_flush_enabled_config(),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
assert calls == ["compact"]
await asyncio.sleep(0)
assert "flush" in calls
flush_service.execute.assert_awaited_once_with(
entries,
"agent:ops:long-session",
agent_id="ops",
message_window=0,
segment_mode="auto",
timeout=120.0,
raw_capture_policy="required",
turn_id=ANY,
checkpoint_exists=False,
)
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
@pytest.mark.parametrize(
"receipt",
[
_flush_receipt(mode="raw", raw_reason="no_provider"),
_flush_receipt(integrity_status="missing_chunks"),
_flush_receipt(output_coverage_status="coverage_warning"),
_flush_receipt(invalid_candidate_count=1),
_flush_receipt(candidate_missing_ids=["candidate-1"]),
_flush_receipt(obligation_missing_ids=["obligation-1"]),
],
)
@pytest.mark.asyncio
async def test_preflight_degraded_flush_receipts_do_not_block_compaction(
receipt: SimpleNamespace,
) -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(return_value="summary text")
flush_service = MagicMock()
flush_service.execute = AsyncMock(return_value=receipt)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=_flush_enabled_config(),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
await asyncio.sleep(0)
flush_service.execute.assert_awaited_once()
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
@pytest.mark.asyncio
async def test_preflight_strict_flush_receipt_skips_destructive_compaction() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(return_value="summary text")
flush_service = MagicMock()
flush_service.execute = AsyncMock(
return_value=_flush_receipt(integrity_status="missing_chunks")
)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(
flush_enabled=True,
flush_pre_compaction=True,
flush_timeout_seconds=0.25,
flush_background_timeout_seconds=42.0,
flush_compaction_requires_safe_receipt=True,
)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
flush_service.execute.assert_awaited_once()
mock_sm.compact.assert_not_called()
@pytest.mark.asyncio
async def test_preflight_protect_flush_receipt_marks_degraded_forensic() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
sm = _ResultCompactionSessionManager(entries)
flush_service = MagicMock()
flush_service.execute = AsyncMock(
return_value=_flush_receipt(integrity_status="missing_chunks")
)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(
flush_enabled=True,
flush_pre_compaction=True,
flush_timeout_seconds=0.25,
flush_background_timeout_seconds=42.0,
flush_compaction_safety_mode="protect",
)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
await asyncio.sleep(0)
flush_service.execute.assert_awaited_once()
assert sm.compact_with_result_calls == [("agent:ops:long-session", context_window, None)]
assert sm.compact_with_result_kwargs[0]["flush_receipt_status"] == "degraded_forensic"
@pytest.mark.asyncio
async def test_preflight_compact_failure_uses_emergency_ephemeral_history_trim() -> None:
session_key = "agent:ops:preflight-emergency"
context_window = 1000
entries = [
TranscriptEntry(
session_id="test-session-id",
session_key=session_key,
role="user" if index % 2 == 0 else "assistant",
content=f"historic message {index} " + ("x" * 500),
token_count=300,
)
for index in range(8)
]
sm = _FailingResultCompactionSessionManager(entries)
flush_service = MagicMock()
flush_service.execute = AsyncMock(return_value=_flush_receipt(mode="raw"))
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(
flush_enabled=True,
flush_pre_compaction=True,
flush_timeout_seconds=0.25,
flush_background_timeout_seconds=42.0,
flush_compaction_safety_mode="protect",
)
),
)
await runner._maybe_preflight_compact(session_key, context_window)
class _HistoryCapture:
provider = SimpleNamespace(provider_name="test")
def __init__(self) -> None:
self.history: list[Any] = []
def set_history(self, history: list[Any]) -> None:
self.history = history
agent = _HistoryCapture()
summary_context = await runner._load_history(agent, session_key, trim_last_user=False)
assert sm.compact_with_result_calls == [(session_key, context_window, None)]
assert len(await sm.get_transcript(session_key)) == len(entries)
assert 0 < len(agent.history) < len(entries)
assert summary_context is not None
assert "emergency request-scoped compaction" in summary_context.lower()
@pytest.mark.asyncio
async def test_preflight_compact_failure_reports_emergency_without_failed_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_key = "agent:ops:preflight-emergency-event"
context_window = 1000
entries = [
TranscriptEntry(
session_id="test-session-id",
session_key=session_key,
role="user" if index % 2 == 0 else "assistant",
content=f"historic message {index} " + ("x" * 500),
token_count=300,
)
for index in range(8)
]
sm = _FailingResultCompactionSessionManager(entries)
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
runtime_module,
"notify_compaction",
lambda session_key, **payload: events.append((session_key, payload)),
)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=sm)
runner._compaction_failures[session_key] = runtime_module._CompactionFailureState(count=1)
await runner._maybe_preflight_compact(session_key, context_window)
statuses = [payload["status"] for _, payload in events]
assert statuses == ["started", "emergency_ephemeral"]
emergency = events[-1][1]
assert emergency["applied"] is True
assert emergency["durability"] == "request_scoped"
assert emergency["user_visible"] is True
assert emergency["reason"] == "compact_failed"
assert emergency["flush_receipt_status"] == "emergency_ephemeral"
assert runner._compaction_failures[session_key].count == 2
@pytest.mark.asyncio
async def test_preflight_open_circuit_still_uses_request_scoped_emergency_trim(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_key = "agent:ops:preflight-open-circuit"
context_window = 1000
entries = [
TranscriptEntry(
session_id="test-session-id",
session_key=session_key,
role="user" if index % 2 == 0 else "assistant",
content=f"historic message {index} " + ("x" * 500),
token_count=300,
)
for index in range(8)
]
mock_sm = MagicMock()
mock_sm.compact = AsyncMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
runtime_module,
"notify_compaction",
lambda session_key, **payload: events.append((session_key, payload)),
)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
runner._compaction_failures[session_key] = runtime_module._CompactionFailureState(
count=3,
opened_at=runtime_module.time.monotonic(),
)
await runner._maybe_preflight_compact(session_key, context_window)
mock_sm.compact.assert_not_awaited()
assert [payload["status"] for _, payload in events] == ["emergency_ephemeral"]
emergency = events[-1][1]
assert emergency["reason"] == "durable_compaction_circuit_open"
assert emergency["applied"] is True
assert emergency["durability"] == "request_scoped"
assert runner._compaction_failures[session_key].count == 3
@pytest.mark.asyncio
async def test_preflight_empty_summary_uses_emergency_ephemeral_history_trim() -> None:
session_key = "agent:ops:preflight-empty-summary"
context_window = 1000
entries = [
TranscriptEntry(
session_id="test-session-id",
session_key=session_key,
role="user" if index % 2 == 0 else "assistant",
content=f"historic message {index} " + ("x" * 500),
token_count=300,
)
for index in range(8)
]
class _EmptySummarySessionManager(_ResultCompactionSessionManager):
async def compact_with_result(
self,
session_key: str,
context_window_tokens: int,
config: object | None = None,
**kwargs,
) -> SimpleNamespace:
self.compact_with_result_calls.append((session_key, context_window_tokens, config))
self.compact_with_result_kwargs.append(dict(kwargs))
return SimpleNamespace(
summary="",
kept_entries=entries,
removed_count=0,
chunks_processed=0,
summary_source="skipped",
tokens_before=2400,
tokens_after=2400,
remaining_budget_tokens=0,
)
sm = _EmptySummarySessionManager(entries)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=sm)
await runner._maybe_preflight_compact(session_key, context_window)
class _HistoryCapture:
provider = SimpleNamespace(provider_name="test")
def __init__(self) -> None:
self.history: list[Any] = []
def set_history(self, history: list[Any]) -> None:
self.history = history
agent = _HistoryCapture()
summary_context = await runner._load_history(agent, session_key, trim_last_user=False)
assert sm.compact_with_result_calls == [(session_key, context_window, None)]
assert len(await sm.get_transcript(session_key)) == len(entries)
assert 0 < len(agent.history) < len(entries)
assert summary_context is not None
assert "emergency request-scoped compaction" in summary_context.lower()
@pytest.mark.asyncio
async def test_preflight_stale_preimage_skip_does_not_use_emergency_trim(
monkeypatch: pytest.MonkeyPatch,
) -> None:
session_key = "agent:ops:preflight-stale-summary"
context_window = 1000
entries = [
TranscriptEntry(
session_id="test-session-id",
session_key=session_key,
role="user" if index % 2 == 0 else "assistant",
content=f"historic message {index} " + ("x" * 500),
token_count=300,
)
for index in range(8)
]
sm = _StaleResultCompactionSessionManager(entries)
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
runtime_module,
"notify_compaction",
lambda session_key, **payload: events.append((session_key, payload)),
)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=sm)
runner._compaction_failures[session_key] = runtime_module._CompactionFailureState(count=1)
await runner._maybe_preflight_compact(session_key, context_window)
assert sm.compact_with_result_calls == [(session_key, context_window, None)]
assert runner.has_compacted_this_turn(session_key) is False
assert runner._compaction_failures[session_key].count == 1
skipped = [payload for _, payload in events if payload.get("status") == "skipped"]
assert skipped[-1]["reason"] == "stale_preimage"
assert skipped[-1]["applied"] is False
assert skipped[-1]["durability"] == "none"
assert skipped[-1]["user_visible"] is False
class _HistoryCapture:
provider = SimpleNamespace(provider_name="test")
def __init__(self) -> None:
self.history: list[Any] = []
def set_history(self, history: list[Any]) -> None:
self.history = history
agent = _HistoryCapture()
summary_context = await runner._load_history(agent, session_key, trim_last_user=False)
assert summary_context is None
assert len(agent.history) == len(entries)
@pytest.mark.asyncio
async def test_preflight_backfilled_flush_receipt_allows_compact() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(return_value="summary text")
flush_service = MagicMock()
flush_service.execute = AsyncMock(return_value=_flush_receipt(obligation_status="backfilled"))
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=_flush_enabled_config(),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
await asyncio.sleep(0)
flush_service.execute.assert_awaited_once()
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
@pytest.mark.asyncio
async def test_preflight_uses_background_timeout_for_flush_service() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(return_value="summary text")
flush_service = MagicMock()
flush_service.execute = AsyncMock(return_value=_flush_receipt())
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(
flush_enabled=True,
flush_pre_compaction=True,
flush_timeout_seconds=0.25,
flush_background_timeout_seconds=42.0,
)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
await asyncio.sleep(0)
assert flush_service.execute.await_args.kwargs["timeout"] == 42.0
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
@pytest.mark.asyncio
async def test_preflight_flush_grace_timeout_does_not_block_compaction() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(return_value="summary text")
flush_service = MagicMock()
async def slow_flush(*_args, **_kwargs):
import asyncio
await asyncio.sleep(0.05)
return _flush_receipt()
flush_service.execute = AsyncMock(side_effect=slow_flush)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(
flush_enabled=True,
flush_pre_compaction=True,
flush_timeout_seconds=0.001,
flush_background_timeout_seconds=42.0,
)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
await asyncio.sleep(0)
assert flush_service.execute.await_args.kwargs["timeout"] == 42.0
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
await asyncio.sleep(0.06)
@pytest.mark.asyncio
async def test_preflight_memory_flush_disabled_compacts_without_flush() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(return_value="summary text")
flush_service = MagicMock()
flush_service.execute = AsyncMock(return_value=_flush_receipt())
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(flush_enabled=False, flush_timeout_seconds=0.25)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
flush_service.execute.assert_not_called()
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
@pytest.mark.parametrize("value", ["0", "false", "no", "off"])
@pytest.mark.asyncio
async def test_preflight_env_flush_disabled_compacts_without_flush(
monkeypatch: pytest.MonkeyPatch,
value: str,
) -> None:
monkeypatch.setenv("OPENSQUILLA_SESSION_FLUSH", value)
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(return_value="summary text")
flush_service = MagicMock()
flush_service.execute = AsyncMock(return_value=_flush_receipt())
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=SimpleNamespace(
memory=SimpleNamespace(flush_enabled=True, flush_timeout_seconds=0.25)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
flush_service.execute.assert_not_called()
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
@pytest.mark.asyncio
async def test_preflight_flush_service_unavailable_does_not_block_compaction() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
mock_sm.compact = AsyncMock(return_value="summary text")
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=None,
config=SimpleNamespace(
memory=SimpleNamespace(flush_enabled=True, flush_timeout_seconds=0.25)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
mock_sm.compact.assert_awaited_once_with("agent:ops:long-session", context_window)
@pytest.mark.asyncio
async def test_preflight_passes_provider_backed_compaction_config_after_flush() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
captured_configs: list[CompactionConfig | None] = []
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
async def compact(session_key, context_window_tokens, config=None):
captured_configs.append(config)
return "summary text"
mock_sm.compact = AsyncMock(side_effect=compact)
flush_service = MagicMock()
flush_service.execute = AsyncMock(return_value=_flush_receipt())
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
session_flush_service=flush_service,
config=SimpleNamespace(
compaction=SimpleNamespace(enabled=True, model=None, timeout_seconds=17.5)
),
)
provider = _FakeCompactionProvider(model="provider/model")
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact(
"agent:ops:long-session",
context_window,
compaction_provider=provider,
compaction_model="routed/model",
)
assert len(captured_configs) == 1
config = captured_configs[0]
assert isinstance(config, CompactionConfig)
assert config.api_key == "preflight-provider-key"
assert config.model == "routed/model"
assert config.base_url == "https://openrouter.ai/api/v1"
assert config.timeout_seconds == 17.5
@pytest.mark.asyncio
async def test_preflight_passes_profile_config_without_provider_or_model() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
sm = _ResultCompactionSessionManager(entries)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=sm,
config=SimpleNamespace(
compaction=SimpleNamespace(
enabled=False,
compaction_profile="coding",
protected_recent_messages=7,
timeout_seconds=17.5,
)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact("agent:ops:long-session", context_window)
assert len(sm.compact_with_result_calls) == 1
config = sm.compact_with_result_calls[0][2]
assert isinstance(config, CompactionConfig)
assert config.model is None
assert config.api_key == ""
assert config.compaction_profile == "coding"
assert config.protected_recent_messages == 7
assert config.timeout_seconds == 17.5
@pytest.mark.asyncio
async def test_preflight_keeps_legacy_compact_manager_compatible() -> None:
context_window = 1000
entries = [_make_entry("early durable fact " + ("a" * 4000))]
calls: list[tuple[str, int]] = []
mock_sm = MagicMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
async def compact(session_key, context_window_tokens):
calls.append((session_key, context_window_tokens))
return "summary text"
mock_sm.compact = AsyncMock(side_effect=compact)
runner = TurnRunner(
provider_selector=MagicMock(),
session_manager=mock_sm,
config=SimpleNamespace(
compaction=SimpleNamespace(enabled=True, model=None, timeout_seconds=17.5)
),
)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact(
"agent:ops:long-session",
context_window,
compaction_provider=_FakeCompactionProvider(model="provider/model"),
compaction_model="routed/model",
)
assert calls == [("agent:ops:long-session", context_window)]
@pytest.mark.asyncio
async def test_run_falls_back_to_generic_preflight_after_t3_flush_failed() -> None:
selector = _FakeProviderSelector()
runner = TurnRunner(provider_selector=selector, config=GatewayConfig())
seen: dict[str, object] = {}
async def fake_t3(session_key, turn, context_window_tokens, **kwargs):
seen["t3_session_key"] = session_key
return "flush_failed"
async def spy_preflight(session_key, context_window_tokens, **kwargs):
seen["preflight_session_key"] = session_key
seen.update(kwargs)
runner._maybe_compact_on_t3_upgrade = fake_t3 # type: ignore[method-assign]
runner._maybe_preflight_compact = spy_preflight # type: ignore[method-assign]
tool_ctx = ToolContext(is_owner=True, caller_kind=CallerKind.CLI)
async for _ in runner.run(
"hello",
"agent:main:abc123",
tool_context=tool_ctx,
model="routed/model",
):
pass
assert seen["t3_session_key"] == "agent:main:abc123"
assert seen["preflight_session_key"] == "agent:main:abc123"
assert seen["compaction_model"] == "routed/model"
@pytest.mark.asyncio
async def test_run_forwards_routed_provider_and_model_to_preflight() -> None:
selector = _FakeProviderSelector()
runner = TurnRunner(
provider_selector=selector,
config=GatewayConfig(),
model_catalog=ModelCatalog(),
)
seen: dict[str, object] = {}
async def spy_preflight(session_key, context_window_tokens, **kwargs):
seen["session_key"] = session_key
seen["context_window_tokens"] = context_window_tokens
seen.update(kwargs)
runner._maybe_preflight_compact = spy_preflight # type: ignore[method-assign]
tool_ctx = ToolContext(is_owner=True, caller_kind=CallerKind.CLI)
async for _ in runner.run(
"hello",
"agent:main:abc123",
tool_context=tool_ctx,
model="z-ai/glm-5.1",
):
pass
assert seen["session_key"] == "agent:main:abc123"
# Conservative-min static fallback for glm-5.1 (formerly 202_752 under the
# z-ai/ spelling; merged with the bare glm-5.1 entry's 200_000 window).
assert seen["context_window_tokens"] == 200_000
assert seen["compaction_model"] == "z-ai/glm-5.1"
assert getattr(seen["compaction_provider"], "model") == "z-ai/glm-5.1"
assert selector.override_calls == []
assert selector.clone_instance.override_calls[-1] == "z-ai/glm-5.1"
@pytest.mark.asyncio
async def test_preflight_compact_called_with_correct_args() -> None:
"""compact() is called with (session_key, context_window_tokens)."""
entries = [_make_entry("content")]
mock_sm = MagicMock()
mock_sm.compact = AsyncMock(return_value="")
mock_sm.get_transcript = AsyncMock(return_value=entries)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=90_000):
await runner._maybe_preflight_compact("user:long-session", 100_000)
mock_sm.compact.assert_called_once_with("user:long-session", 100_000)
@pytest.mark.asyncio
async def test_preflight_exactly_at_threshold_does_not_compact() -> None:
"""Transcript at exactly the threshold (not exceeding) → no compaction."""
context_window = 1000
threshold = int(context_window * 0.85) # default threshold: 850
entries = [_make_entry("a")]
mock_sm = MagicMock()
mock_sm.compact = AsyncMock()
mock_sm.get_transcript = AsyncMock(return_value=entries)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=threshold):
await runner._maybe_preflight_compact("user:session", context_window)
mock_sm.compact.assert_not_called()
@pytest.mark.asyncio
async def test_preflight_integration_with_real_session_manager(session_mgr, tmp_path) -> None:
"""Integration: pre-flight with real SessionManager compacts when over threshold."""
mgr = session_mgr
key = "user:preflight-test"
await mgr.create(key)
# Seed transcript entries
await mgr.append_message(key, role="user", content="message one")
await mgr.append_message(key, role="assistant", content="reply one")
await mgr.append_message(key, role="user", content="message two")
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mgr)
# Patch compact_with_result() to verify the metadata-preserving path is used.
original_compact_with_result = mgr.compact_with_result
compact_calls: list[dict[str, object]] = []
async def _spy_compact_with_result(
session_key,
context_window_tokens,
config=None,
*,
compaction_id=None,
trigger_reason=None,
flush_receipt_status=None,
):
compact_calls.append(
{
"session_key": session_key,
"context_window_tokens": context_window_tokens,
"compaction_id": compaction_id,
"trigger_reason": trigger_reason,
"flush_receipt_status": flush_receipt_status,
}
)
return await original_compact_with_result(
session_key,
context_window_tokens,
config,
compaction_id=compaction_id,
trigger_reason=trigger_reason,
flush_receipt_status=flush_receipt_status,
)
mgr.compact_with_result = _spy_compact_with_result # type: ignore[method-assign]
# Force all tokens to exceed the default threshold (100 * 0.85 = 85)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
await runner._maybe_preflight_compact(key, 100)
# compact_with_result() was invoked with the correct args.
assert len(compact_calls) == 1
assert compact_calls[0]["session_key"] == key
assert compact_calls[0]["context_window_tokens"] == 100
assert compact_calls[0]["compaction_id"]
assert compact_calls[0]["trigger_reason"] == "preflight"
assert compact_calls[0]["flush_receipt_status"] == "not_required"
receipts = await mgr.storage.list_memory_durable_receipts(
session_key=key,
status="checkpoint_saved",
limit=1,
)
assert receipts
assert receipts[0].scope == "checkpoint"
assert receipts[0].status == "checkpoint_saved"
assert receipts[0].source_path
assert (tmp_path / receipts[0].source_path).exists()
@pytest.mark.asyncio
async def test_preflight_compaction_circuit_breaker_retries_after_cooldown() -> None:
context_window = 1000
entries = [_make_entry("a" * 4000)]
mock_sm = MagicMock()
mock_sm.compact = AsyncMock(side_effect=RuntimeError("compact failed"))
mock_sm.get_transcript = AsyncMock(return_value=entries)
runner = TurnRunner(provider_selector=MagicMock(), session_manager=mock_sm)
with patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000):
for _ in range(4):
await runner._maybe_preflight_compact("user:session", context_window)
runner.clear_compaction_turn_state("user:session")
assert mock_sm.compact.await_count == 3
runner._compaction_failures["user:session"].opened_at = 0.0
with (
patch("opensquilla.session.tokenizer.estimate_tokens", return_value=1000),
patch("opensquilla.engine.runtime.time.monotonic", return_value=999.0),
):
await runner._maybe_preflight_compact("user:session", context_window)
assert mock_sm.compact.await_count == 4