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

1331 lines
44 KiB
Python

"""Tests for the context-overflow policy branches."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from opensquilla.gateway import context_overflow
from opensquilla.gateway.config import ContextOverflowPolicy, GatewayConfig
from opensquilla.gateway.context_overflow import (
OverflowOutcome,
apply_context_overflow_policy,
)
from opensquilla.gateway.rpc_chat import _enforce_context_overflow, _handle_chat_send
from opensquilla.session.compaction import CompactionConfig
from opensquilla.session.compaction_state import (
StructuredCompactionSummary,
render_structured_summary,
)
from opensquilla.session.models import SessionContextState, SessionSummary
from opensquilla.session.tokenizer import estimate_tokens
@dataclass
class _FakeEntry:
content: str
tool_calls: list[dict[str, Any]] | None = None
reasoning_content: str | None = None
token_count: int | None = None
class _FakeSessionManager:
"""Minimal session-manager stub: tracks compact() calls + transcript."""
def __init__(self, transcript: list[_FakeEntry]) -> None:
self._transcript = list(transcript)
self.compact_calls: list[tuple[str, int, object | None]] = []
self.compact_kwargs: list[dict[str, Any]] = []
async def get_transcript(self, session_key: str) -> list[_FakeEntry]:
return list(self._transcript)
async def compact(self, session_key: str, budget: int, config=None) -> str:
# Simulate a successful compaction: collapse history into a single
# short summary entry so the next estimate fits easily.
self.compact_calls.append((session_key, budget, config))
self._transcript = [_FakeEntry(content="[summary]")]
return "[summary]"
class _ResultCompactionSessionManager(_FakeSessionManager):
async def compact_with_result(self, session_key: str, budget: int, config=None, **kwargs):
self.compact_calls.append((session_key, budget, config))
self.compact_kwargs.append(dict(kwargs))
self._transcript = [_FakeEntry(content="[summary]")]
return SimpleNamespace(
summary="[summary]",
kept_entries=[{"role": "assistant", "content": "[tail]"}],
removed_count=5,
chunks_processed=2,
summary_source="llm",
tokens_before=900,
tokens_after=90,
remaining_budget_tokens=max(budget - 90, 0),
)
class _InsufficientResultCompactionSessionManager(_FakeSessionManager):
async def compact_with_result(self, session_key: str, budget: int, config=None, **kwargs):
self.compact_calls.append((session_key, budget, config))
self.compact_kwargs.append(dict(kwargs))
self._transcript = [_FakeEntry(content="[still too large]", token_count=100)]
return SimpleNamespace(
summary="[summary]",
kept_entries=[{"role": "assistant", "content": "[still too large]"}],
removed_count=5,
chunks_processed=2,
summary_source="llm",
tokens_before=900,
tokens_after=100,
remaining_budget_tokens=0,
)
class _InsufficientCompactionSessionManager(_FakeSessionManager):
async def compact(self, session_key: str, budget: int, config=None) -> str:
self.compact_calls.append((session_key, budget, config))
return "[summary]"
class _FailingCompactionSessionManager(_FakeSessionManager):
async def compact(self, session_key: str, budget: int, config=None) -> str:
self.compact_calls.append((session_key, budget, config))
raise RuntimeError("compact boom")
class _LegacyCompactSessionManager(_FakeSessionManager):
async def compact(self, session_key: str, budget: int) -> str:
self.compact_calls.append((session_key, budget, None))
self._transcript = [_FakeEntry(content="[summary]")]
return "[summary]"
class _CheckpointingSessionManager(_FakeSessionManager):
def __init__(self, transcript: list[_FakeEntry]) -> None:
super().__init__(transcript)
self.calls: list[str] = []
async def record_memory_checkpoint(
self,
session_key: str,
transcript: list[_FakeEntry],
**kwargs,
) -> SimpleNamespace:
self.calls.append("checkpoint")
return SimpleNamespace(
scope="checkpoint",
status="checkpoint_saved",
source_path="memory/.checkpoints/s/turn.jsonl",
content_hash="h1",
)
async def compact(self, session_key: str, budget: int, config=None) -> str:
self.calls.append("compact")
return await super().compact(session_key, budget, config)
class _InvalidCheckpointSessionManager(_CheckpointingSessionManager):
async def record_memory_checkpoint(
self,
session_key: str,
transcript: list[_FakeEntry],
**kwargs,
) -> SimpleNamespace:
self.calls.append("checkpoint")
return SimpleNamespace(
scope="checkpoint",
status="checkpoint_failed",
source_path="memory/.checkpoints/s/turn.jsonl",
content_hash="h1",
)
class _FailingCheckpointSessionManager(_CheckpointingSessionManager):
async def record_memory_checkpoint(
self,
session_key: str,
transcript: list[_FakeEntry],
**kwargs,
) -> None:
self.calls.append("checkpoint")
raise RuntimeError("checkpoint write failed")
class _SummaryReadFailureSessionManager(_FakeSessionManager):
async def get_summaries(self, session_key: str) -> list[Any]:
raise RuntimeError(f"summary store unavailable for {session_key}")
class _StructuredContextSessionManager(_FakeSessionManager):
def __init__(
self,
transcript: list[_FakeEntry],
*,
summaries: list[SessionSummary],
context_states: list[SessionContextState],
) -> None:
super().__init__(transcript)
self._summaries = list(summaries)
self._context_states = list(context_states)
async def get_summaries(self, session_key: str) -> list[SessionSummary]:
return list(self._summaries)
async def get_context_states(self, session_key: str) -> list[SessionContextState]:
return list(self._context_states)
class _FakeCompactionProvider:
provider_name = "openai"
def __init__(self) -> None:
self._api_key = "overflow-provider-key"
self._model = "provider/model"
self._base_url = "https://openrouter.ai/api/v1"
@property
def model(self) -> str:
return self._model
class _FakeSelectorClone:
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
def resolve(self) -> _FakeCompactionProvider:
return self.provider
class _FakeProviderSelector:
def __init__(self) -> None:
self.provider = _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 _TurnCompactionMarker:
def __init__(self, compacted: set[str] | None = None) -> None:
self.compacted = set(compacted or set())
self.mark_calls: list[str] = []
self.clear_calls: list[str] = []
def has_compacted_this_turn(self, session_key: str) -> bool:
return session_key in self.compacted
def mark_compacted_this_turn(self, session_key: str) -> None:
self.mark_calls.append(session_key)
self.compacted.add(session_key)
def clear_compacted_this_turn(self, session_key: str) -> None:
self.clear_calls.append(session_key)
self.compacted.discard(session_key)
class _FailingTurnCompactionMarker:
def has_compacted_this_turn(self, session_key: str) -> bool:
raise RuntimeError(f"marker unavailable for {session_key}")
def _cfg(
policy: ContextOverflowPolicy,
budget: int = 20,
*,
flush_enabled: bool = False,
flush_timeout_seconds: float = 5.0,
flush_background_timeout_seconds: float = 60.0,
flush_pre_compaction: bool | None = None,
flush_compaction_requires_safe_receipt: bool = False,
flush_compaction_safety_mode: str | None = None,
) -> GatewayConfig:
memory: dict[str, object] = {
"flush_enabled": flush_enabled,
"flush_timeout_seconds": flush_timeout_seconds,
"flush_background_timeout_seconds": flush_background_timeout_seconds,
"flush_compaction_requires_safe_receipt": (flush_compaction_requires_safe_receipt),
}
if flush_pre_compaction is None:
flush_pre_compaction = flush_enabled
memory["flush_pre_compaction"] = flush_pre_compaction
if flush_compaction_safety_mode is not None:
memory["flush_compaction_safety_mode"] = flush_compaction_safety_mode
return GatewayConfig(
context_overflow_policy=policy,
context_budget_tokens=budget,
memory=memory,
)
def test_gateway_memory_flush_triggers_normalize_aliases() -> None:
string_cfg = GatewayConfig(memory={"flush_triggers": "reset, inline_overflow"})
list_cfg = GatewayConfig(memory={"flush_triggers": ["manual", "pre-compaction"]})
assert string_cfg.memory.flush_triggers == ["session_reset", "pre_compaction"]
assert list_cfg.memory.flush_triggers == ["manual", "pre_compaction"]
@pytest.mark.parametrize(
"flush_triggers",
[
"bogus",
["manual", "bogus"],
],
)
def test_gateway_memory_flush_triggers_reject_unknown_values(
flush_triggers: object,
) -> None:
with pytest.raises(ValueError, match="unknown flush trigger"):
GatewayConfig(memory={"flush_triggers": flush_triggers})
def _history(n_entries: int, chars_per_entry: int) -> list[_FakeEntry]:
# estimate_tokens rounds chars/4, so ~4 chars ≈ 1 token.
return [_FakeEntry(content="x" * chars_per_entry) for _ in range(n_entries)]
@pytest.mark.asyncio
async def test_default_policy_is_auto_summarize() -> None:
"""GatewayConfig default policy must be AUTO_SUMMARIZE per S4 AC."""
cfg = GatewayConfig()
assert cfg.context_overflow_policy == ContextOverflowPolicy.AUTO_SUMMARIZE
assert cfg.context_budget_tokens == 100_000
@pytest.mark.asyncio
async def test_policy_enum_has_exactly_three_members() -> None:
"""Locks S4 AC: exactly three policy options, stable string values."""
values = {m.value for m in ContextOverflowPolicy}
assert values == {"auto_summarize", "hard_truncate", "refuse"}
@pytest.mark.asyncio
async def test_under_budget_is_noop() -> None:
cfg = _cfg(ContextOverflowPolicy.REFUSE, budget=10_000)
outcome = await apply_context_overflow_policy(
config=cfg,
message="hi",
transcript=_history(1, 4),
session_key="s1",
)
assert outcome.over_budget is False
assert outcome.refusal is None
@pytest.mark.asyncio
async def test_refuse_returns_stable_error_envelope() -> None:
"""REFUSE short-circuits with the documented error envelope."""
cfg = _cfg(ContextOverflowPolicy.REFUSE, budget=5)
outcome = await apply_context_overflow_policy(
config=cfg,
message="hello",
transcript=_history(4, 40),
session_key="s-refuse",
)
assert outcome.over_budget is True
assert outcome.refusal is not None
env = outcome.refusal
assert env["status"] == "error"
assert env["error_class"] == "context_overflow"
assert env["retry_allowed"] is False
assert isinstance(env["user_message"], str) and env["user_message"]
@pytest.mark.asyncio
async def test_gateway_context_overflow_counts_tool_call_arguments() -> None:
cfg = _cfg(ContextOverflowPolicy.REFUSE, budget=100)
transcript = [
_FakeEntry(
content="small visible answer",
token_count=1,
tool_calls=[
{
"type": "tool_use",
"tool_use_id": "write-stale",
"name": "write_file",
"input": {"path": "index.html", "content": "x" * 5000},
}
],
)
]
outcome = await apply_context_overflow_policy(
config=cfg,
message="next",
transcript=transcript,
session_key="s-tool-payload",
)
assert outcome.over_budget is True
assert outcome.refusal is not None
assert outcome.estimated_tokens > cfg.context_budget_tokens
@pytest.mark.asyncio
async def test_gateway_context_overflow_counts_reasoning_content() -> None:
cfg = _cfg(ContextOverflowPolicy.REFUSE, budget=100)
transcript = [
_FakeEntry(
content="small visible answer",
token_count=1,
reasoning_content="reasoning " + ("r" * 5000),
)
]
outcome = await apply_context_overflow_policy(
config=cfg,
message="next",
transcript=transcript,
session_key="s-reasoning-payload",
)
assert outcome.over_budget is True
assert outcome.refusal is not None
assert outcome.estimated_tokens > cfg.context_budget_tokens
@pytest.mark.asyncio
async def test_hard_truncate_drops_oldest_history_until_fits() -> None:
"""HARD_TRUNCATE removes oldest entries one at a time to fit the budget."""
cfg = _cfg(ContextOverflowPolicy.HARD_TRUNCATE, budget=10)
transcript = _history(5, 40) # 5 * 40 chars ≈ 50 tokens per estimate_tokens
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=transcript,
session_key="s-trunc",
)
assert outcome.over_budget is True
assert outcome.truncated_entries > 0
# Some entries were dropped; remaining history is shorter than input.
assert len(outcome.trimmed_history) == len(transcript) - outcome.truncated_entries
@pytest.mark.asyncio
async def test_auto_summarize_invokes_compaction_and_retries_once() -> None:
"""AUTO_SUMMARIZE retries only after compacted payload is inside budget."""
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _FakeSessionManager(_history(6, 40))
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-auto",
session_manager=sm,
)
assert outcome.over_budget is True
assert outcome.summarized is True
assert outcome.retried is True
assert len(sm.compact_calls) == 1
assert sm.compact_calls[0][0] == "s-auto"
assert outcome.tokens_after is not None
assert outcome.remaining_budget_tokens is not None
assert outcome.tokens_after <= outcome.budget_tokens
@pytest.mark.asyncio
async def test_auto_summarize_checkpoint_runs_before_compact() -> None:
sm = _CheckpointingSessionManager(_history(6, 40))
outcome = await apply_context_overflow_policy(
config=_cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10),
message="m",
transcript=sm._transcript,
session_key="s-checkpoint",
session_manager=sm,
)
assert outcome.summarized is True
assert sm.calls[0] == "checkpoint"
assert "compact" in sm.calls
@pytest.mark.asyncio
async def test_auto_summarize_checkpoint_failure_propagates_without_ephemeral_fallback() -> None:
sm = _FailingCheckpointSessionManager(_history(6, 40))
with pytest.raises(RuntimeError, match="checkpoint write failed"):
await apply_context_overflow_policy(
config=_cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10),
message="m",
transcript=sm._transcript,
session_key="s-checkpoint-fails",
session_manager=sm,
)
assert sm.calls == ["checkpoint"]
assert sm.compact_calls == []
@pytest.mark.asyncio
async def test_auto_summarize_emits_started_and_completed_events(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _ResultCompactionSessionManager(_history(6, 40))
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
context_overflow,
"notify_compaction",
lambda session_key, **payload: events.append((session_key, payload)),
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-auto-events",
session_manager=sm,
)
assert outcome.summarized is True
assert [(key, payload["status"]) for key, payload in events] == [
("s-auto-events", "started"),
("s-auto-events", "observed"),
("s-auto-events", "observed"),
("s-auto-events", "completed"),
]
assert all(payload["source"] == "automatic" for _, payload in events)
assert all(payload["phase"] == "gateway_auto_summarize" for _, payload in events)
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["event"] == "compaction.replayed"
assert completed["event_chain"] == [
"compaction.triggered",
"compaction.chunk_summarized",
"compaction.summary_verified",
"compaction.persisted",
"compaction.replayed",
]
assert completed["coverage_status"] == "unknown"
assert completed["chunk_count"] == 2
assert completed["summary_source"] == "llm"
assert completed["removed_count"] == 5
assert completed["kept_count"] == 1
assert completed["tokens_after"] < completed["tokens_before"]
assert completed["applied"] is True
assert completed["durability"] == "durable"
assert completed["user_visible"] is True
@pytest.mark.asyncio
async def test_auto_summarize_reports_durable_effect_when_request_still_over_budget(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _InsufficientResultCompactionSessionManager(_history(6, 40))
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
context_overflow,
"notify_compaction",
lambda session_key, **payload: events.append((session_key, payload)),
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-auto-durable-refused",
session_manager=sm,
)
assert outcome.summarized is False
assert outcome.lifecycle is not None
assert outcome.lifecycle.compacted is True
assert outcome.lifecycle.refused is True
failed = events[-1][1]
assert failed["status"] == "failed"
assert failed["request_status"] == "refused"
assert failed["reason"] == "compaction_insufficient"
assert failed["applied"] is True
assert failed["durability"] == "durable"
assert failed["removed_count"] == 5
@pytest.mark.asyncio
async def test_auto_summarize_uses_ephemeral_trim_on_compaction_exception(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _FailingCompactionSessionManager(_history(6, 40))
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
context_overflow,
"notify_compaction",
lambda session_key, **payload: events.append((session_key, payload)),
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-auto-failed",
session_manager=sm,
)
assert outcome.summarized is False
assert outcome.reason == "emergency_ephemeral"
assert outcome.refusal is None
assert outcome.retried is True
assert [payload["status"] for _, payload in events] == ["started", "emergency_ephemeral"]
assert events[-1][1]["reason"] == "emergency_ephemeral"
assert events[-1][1]["applied"] is True
assert events[-1][1]["durability"] == "request_scoped"
assert events[-1][1]["user_visible"] is True
assert "compact boom" in events[-1][1]["message"]
@pytest.mark.asyncio
async def test_auto_summarize_uses_ephemeral_trim_when_marker_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _FakeSessionManager(_history(6, 40))
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
context_overflow,
"notify_compaction",
lambda session_key, **payload: events.append((session_key, payload)),
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-marker-failed",
session_manager=sm,
compaction_marker=_FailingTurnCompactionMarker(),
)
assert outcome.summarized is False
assert outcome.reason == "emergency_ephemeral"
assert outcome.refusal is None
assert outcome.retried is True
assert outcome.flush_receipt is None
assert sm.compact_calls == []
assert [payload["status"] for _, payload in events] == ["emergency_ephemeral"]
assert events[-1][1]["reason"] == "emergency_ephemeral"
assert events[-1][1]["durability"] == "request_scoped"
assert events[-1][1]["flush_receipt_status"] == "not_required"
assert "marker unavailable" in events[-1][1]["message"]
@pytest.mark.asyncio
async def test_auto_summarize_refuses_when_compaction_still_exceeds_budget() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _InsufficientCompactionSessionManager(_history(6, 40))
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-insufficient",
session_manager=sm,
)
assert outcome.over_budget is True
assert outcome.summarized is False
assert outcome.retried is False
assert outcome.reason == "compaction_insufficient"
assert outcome.refusal is not None
assert outcome.refusal["error"]["reason"] == "compaction_insufficient"
assert len(sm.compact_calls) == 1
@pytest.mark.asyncio
async def test_session_payload_estimate_counts_rendered_structured_context_state() -> None:
structured = StructuredCompactionSummary(
user_goal="Finish the migration",
current_status="Current status has enough detail to exceed the plain summary.",
next_action="Run focused regression tests",
files_and_artifacts=[
{
"path": "src/opensquilla/session/context_view.py",
"status": "changed",
"why": "provider-visible compaction context is rendered here",
}
],
critical_carry_forward=[
"Do not rely on summary_text when structured context state is valid."
],
)
summary = SessionSummary(
session_id="sid",
session_key="s-structured",
summary_text="short summary",
covered_through_id=7,
)
state = SessionContextState(
session_id="sid",
session_key="s-structured",
provider="portable",
state_kind="structured_summary_v1",
payload=structured.model_dump(mode="json"),
covered_through_id=7,
portable=True,
valid=True,
)
sm = _StructuredContextSessionManager([], summaries=[summary], context_states=[state])
total = await context_overflow._estimate_session_payload_tokens(
"",
[],
session_manager=sm,
session_key="s-structured",
)
assert total == context_overflow._estimate_payload_tokens("", []) + estimate_tokens(
render_structured_summary(structured)
)
@pytest.mark.asyncio
async def test_auto_summarize_uses_fallback_summary_when_context_cannot_be_verified() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _SummaryReadFailureSessionManager(_history(6, 40))
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-summary-fail",
session_manager=sm,
)
assert outcome.over_budget is True
assert outcome.summarized is True
assert outcome.retried is True
assert outcome.reason is None
assert outcome.refusal is None
assert len(sm.compact_calls) == 1
@pytest.mark.asyncio
async def test_auto_summarize_compacts_while_protect_flush_runs_in_background() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10, flush_enabled=True)
sm = _FakeSessionManager(_history(6, 40))
flush_service = SimpleNamespace(
execute=AsyncMock(
return_value=SimpleNamespace(
mode="llm",
integrity_ok=False,
output_coverage_status="ok",
missing_candidate_count=0,
invalid_candidate_count=0,
obligation_status="ok",
)
)
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-flush",
session_manager=sm,
flush_service=flush_service,
)
assert outcome.over_budget is True
assert outcome.summarized is True
assert outcome.retried is True
assert outcome.reason is None
assert outcome.refusal is None
assert outcome.flush_receipt is None
assert outcome.lifecycle is not None
assert outcome.lifecycle.flush_receipt is outcome.flush_receipt
assert outcome.lifecycle.refused is False
assert sm.compact_calls == [("agent:main:s-flush", 10, None)]
await asyncio.sleep(0)
flush_service.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_auto_summarize_flush_enabled_without_trigger_skips_flush_service() -> None:
cfg = _cfg(
ContextOverflowPolicy.AUTO_SUMMARIZE,
budget=10,
flush_enabled=True,
flush_pre_compaction=False,
)
sm = _FakeSessionManager(_history(6, 40))
flush_service = SimpleNamespace(execute=AsyncMock())
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-flush-disabled-trigger",
session_manager=sm,
flush_service=flush_service,
)
assert outcome.over_budget is True
assert outcome.summarized is True
assert outcome.retried is True
assert outcome.flush_receipt is None
flush_service.execute.assert_not_called()
assert sm.compact_calls == [("agent:main:s-flush-disabled-trigger", 10, None)]
@pytest.mark.asyncio
async def test_auto_summarize_compacts_when_distill_fails_after_checkpoint() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10, flush_enabled=True)
sm = _CheckpointingSessionManager(_history(6, 40))
flush_service = SimpleNamespace(
execute=AsyncMock(side_effect=RuntimeError("bad json"))
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-distill-fails",
session_manager=sm,
flush_service=flush_service,
)
assert outcome.over_budget is True
assert outcome.summarized is True
assert outcome.retried is True
assert outcome.reason is None
assert outcome.refusal is None
assert outcome.flush_receipt is None
assert sm.calls == ["checkpoint", "compact"]
assert sm.compact_calls == [("agent:main:s-distill-fails", 10, None)]
await asyncio.sleep(0)
assert flush_service.execute.await_args.kwargs["message_window"] == 0
@pytest.mark.asyncio
async def test_auto_summarize_strict_semantic_failure_after_checkpoint_refuses() -> None:
cfg = _cfg(
ContextOverflowPolicy.AUTO_SUMMARIZE,
budget=10,
flush_enabled=True,
flush_compaction_requires_safe_receipt=True,
)
sm = _CheckpointingSessionManager(_history(6, 40))
flush_service = SimpleNamespace(
execute=AsyncMock(
return_value=SimpleNamespace(
mode="error",
result_status="archive_failed",
flushed_paths=[],
content_hash="h1",
indexed_chunk_count=0,
integrity_status="unverified",
output_coverage_status="unverified",
invalid_candidate_count=0,
candidate_missing_ids=[],
obligation_status="unverified",
obligation_missing_ids=[],
)
)
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-distill-fails-after-checkpoint",
session_manager=sm,
flush_service=flush_service,
)
assert outcome.over_budget is True
assert outcome.summarized is False
assert outcome.retried is False
assert outcome.reason == "compaction_flush_failed"
assert outcome.refusal is not None
assert sm.calls == ["checkpoint"]
assert sm.compact_calls == []
@pytest.mark.asyncio
async def test_auto_summarize_strict_invalid_checkpoint_receipt_refuses_compaction() -> None:
cfg = _cfg(
ContextOverflowPolicy.AUTO_SUMMARIZE,
budget=10,
flush_enabled=True,
flush_compaction_requires_safe_receipt=True,
)
sm = _InvalidCheckpointSessionManager(_history(6, 40))
flush_service = SimpleNamespace(
execute=AsyncMock(
return_value=SimpleNamespace(
mode="error",
result_status="archive_failed",
flushed_paths=[],
content_hash="h1",
indexed_chunk_count=0,
integrity_status="unverified",
output_coverage_status="unverified",
invalid_candidate_count=0,
candidate_missing_ids=[],
obligation_status="unverified",
obligation_missing_ids=[],
)
)
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-invalid-checkpoint-receipt",
session_manager=sm,
flush_service=flush_service,
)
assert outcome.over_budget is True
assert outcome.summarized is False
assert outcome.retried is False
assert outcome.reason == "compaction_flush_failed"
assert outcome.refusal is not None
assert outcome.refusal["error"]["memory_safety_status"] == "unsafe"
assert outcome.refusal["error"]["semantic_memory_status"] == "failed"
assert sm.calls == ["checkpoint"]
assert sm.compact_calls == []
@pytest.mark.asyncio
async def test_auto_summarize_strict_flush_receipt_refuses_before_compaction() -> None:
cfg = _cfg(
ContextOverflowPolicy.AUTO_SUMMARIZE,
budget=10,
flush_enabled=True,
flush_compaction_requires_safe_receipt=True,
)
sm = _FakeSessionManager(_history(6, 40))
flush_service = SimpleNamespace(
execute=AsyncMock(
return_value=SimpleNamespace(
mode="llm",
integrity_status="missing_chunks",
indexed_chunk_count=1,
output_coverage_status="ok",
invalid_candidate_count=0,
candidate_missing_ids=[],
obligation_status="ok",
obligation_missing_ids=[],
)
)
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-strict-flush",
session_manager=sm,
flush_service=flush_service,
)
assert outcome.over_budget is True
assert outcome.summarized is False
assert outcome.retried is False
assert outcome.reason == "compaction_flush_failed"
assert outcome.refusal is not None
assert outcome.refusal["error"]["reason"] == "compaction_flush_failed"
assert outcome.refusal["error"]["memory_safety_status"] == "unsafe"
assert outcome.refusal["error"]["semantic_memory_status"] == "degraded"
assert outcome.flush_receipt is not None
assert outcome.lifecycle is not None
assert outcome.lifecycle.refused is True
assert outcome.lifecycle.reason == "compaction_flush_failed"
assert sm.compact_calls == []
@pytest.mark.asyncio
async def test_auto_summarize_protect_flush_receipt_degrades_without_refusal() -> None:
cfg = _cfg(
ContextOverflowPolicy.AUTO_SUMMARIZE,
budget=10,
flush_enabled=True,
flush_compaction_safety_mode="protect",
)
sm = _ResultCompactionSessionManager(_history(6, 40))
flush_service = SimpleNamespace(
execute=AsyncMock(
return_value=SimpleNamespace(
mode="llm",
integrity_status="missing_chunks",
indexed_chunk_count=1,
output_coverage_status="ok",
invalid_candidate_count=0,
candidate_missing_ids=[],
obligation_status="ok",
obligation_missing_ids=[],
)
)
)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-protect-flush",
session_manager=sm,
flush_service=flush_service,
)
assert outcome.over_budget is True
assert outcome.summarized is True
assert outcome.retried is True
assert outcome.reason is None
assert outcome.refusal is None
assert sm.compact_calls == [("agent:main:s-protect-flush", 10, None)]
assert sm.compact_kwargs[0]["flush_receipt_status"] == "degraded_forensic"
assert sm.compact_kwargs[0]["trigger_reason"] == "gateway_auto_summarize"
await asyncio.sleep(0)
flush_service.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_auto_summarize_compaction_failure_uses_ephemeral_trim() -> None:
class _FailingSessionManager(_FakeSessionManager):
async def compact(self, session_key: str, budget: int, config=None) -> str:
self.compact_calls.append((session_key, budget, config))
raise RuntimeError("preimage unavailable")
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10, flush_enabled=False)
sm = _FailingSessionManager(_history(6, 40))
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-emergency",
session_manager=sm,
)
assert outcome.over_budget is True
assert outcome.summarized is False
assert outcome.retried is True
assert outcome.refusal is None
assert outcome.reason == "emergency_ephemeral"
assert outcome.truncated_entries > 0
assert len(outcome.trimmed_history) < len(sm._transcript)
@pytest.mark.asyncio
async def test_auto_summarize_compacts_when_flush_service_is_missing() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10, flush_enabled=True)
sm = _FakeSessionManager(_history(6, 40))
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-missing-flush",
session_manager=sm,
)
assert outcome.over_budget is True
assert outcome.summarized is True
assert outcome.retried is True
assert outcome.reason is None
assert outcome.refusal is None
assert sm.compact_calls == [("agent:main:s-missing-flush", 10, None)]
@pytest.mark.asyncio
async def test_auto_summarize_compacts_while_slow_flush_runs_in_background() -> None:
cfg = _cfg(
ContextOverflowPolicy.AUTO_SUMMARIZE,
budget=10,
flush_enabled=True,
flush_timeout_seconds=0.001,
flush_background_timeout_seconds=42.0,
)
sm = _FakeSessionManager(_history(6, 40))
flush_started = asyncio.Event()
flush_release = asyncio.Event()
async def _slow_flush(*args: Any, **kwargs: Any) -> Any:
flush_started.set()
await flush_release.wait()
return SimpleNamespace(
mode="llm",
integrity_ok=True,
output_coverage_status="ok",
missing_candidate_count=0,
invalid_candidate_count=0,
obligation_status="ok",
timeout_seconds=kwargs.get("timeout"),
)
flush_service = SimpleNamespace(execute=AsyncMock(side_effect=_slow_flush))
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="agent:main:s-slow-flush",
session_manager=sm,
flush_service=flush_service,
)
await asyncio.wait_for(flush_started.wait(), timeout=1.0)
assert outcome.over_budget is True
assert outcome.summarized is True
assert outcome.retried is True
assert outcome.reason is None
assert outcome.refusal is None
assert sm.compact_calls == [("agent:main:s-slow-flush", 10, None)]
assert flush_service.execute.await_args.kwargs["timeout"] == 42.0
flush_release.set()
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_auto_summarize_does_not_compact_twice_in_same_turn() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _InsufficientCompactionSessionManager(_history(6, 40))
marker = _TurnCompactionMarker({"s-once"})
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-once",
session_manager=sm,
compaction_marker=marker,
)
assert outcome.over_budget is True
assert outcome.reason == "compaction_insufficient"
assert sm.compact_calls == []
@pytest.mark.asyncio
async def test_failed_auto_summarize_does_not_mark_turn_compacted() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _InsufficientCompactionSessionManager(_history(6, 40))
marker = _TurnCompactionMarker()
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-failed",
session_manager=sm,
compaction_marker=marker,
)
assert outcome.reason == "compaction_insufficient"
assert outcome.compacted_this_turn is False
assert marker.mark_calls == []
assert "s-failed" not in marker.compacted
@pytest.mark.asyncio
async def test_auto_summarize_forwards_compaction_config() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _FakeSessionManager(_history(6, 40))
compaction_config = CompactionConfig(api_key="key", model="model")
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-auto",
session_manager=sm,
compaction_config=compaction_config,
)
assert outcome.summarized is True
assert sm.compact_calls == [("s-auto", 10, compaction_config)]
@pytest.mark.asyncio
async def test_auto_summarize_keeps_legacy_compact_manager_compatible() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _LegacyCompactSessionManager(_history(6, 40))
compaction_config = CompactionConfig(api_key="key", model="model")
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=sm._transcript,
session_key="s-auto",
session_manager=sm,
compaction_config=compaction_config,
)
assert outcome.summarized is True
assert sm.compact_calls == [("s-auto", 10, None)]
@pytest.mark.asyncio
async def test_chat_send_accepts_turn_without_synchronous_context_overflow_gate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = SimpleNamespace(
get_or_create=AsyncMock(return_value=SimpleNamespace(session_key="s-auto")),
)
ctx = SimpleNamespace(
config=cfg,
session_manager=sm,
principal=SimpleNamespace(role="owner"),
)
accepted: dict[str, Any] = {}
async def _unexpected_gate(*args: Any, **kwargs: Any) -> dict[str, Any]:
raise AssertionError("chat.send must not synchronously refuse overflow")
async def _fake_sessions_send(params: dict[str, Any], _ctx: Any) -> dict[str, Any]:
accepted.update(params)
return {"status": "accepted", "key": params["key"], "task_id": "task-long-context"}
monkeypatch.setattr(
"opensquilla.gateway.rpc_chat._enforce_context_overflow",
_unexpected_gate,
)
monkeypatch.setattr(
"opensquilla.gateway.rpc_sessions._handle_sessions_send",
_fake_sessions_send,
)
result = await _handle_chat_send({"message": "m", "sessionKey": "s-auto"}, ctx)
assert result == {
"ok": True,
"sessionKey": "s-auto",
"status": "accepted",
"key": "s-auto",
"task_id": "task-long-context",
}
assert accepted["message"] == "m"
assert accepted["key"] == "s-auto"
def test_chat_send_creates_webchat_session_with_agent_from_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = SimpleNamespace(
get_or_create=AsyncMock(
return_value=SimpleNamespace(
session_key="agent:kid-project:webchat:abc",
agent_id="kid-project",
)
),
)
ctx = SimpleNamespace(
config=cfg,
session_manager=sm,
principal=SimpleNamespace(role="owner"),
)
async def _fake_sessions_send(params: dict[str, Any], _ctx: Any) -> dict[str, Any]:
return {"status": "accepted", "key": params["key"], "task_id": "task-1"}
monkeypatch.setattr(
"opensquilla.gateway.rpc_sessions._handle_sessions_send",
_fake_sessions_send,
)
async def _run() -> dict[str, Any]:
return await _handle_chat_send(
{"message": "m", "sessionKey": "agent:kid-project:webchat:abc"},
ctx,
)
result = asyncio.run(_run())
assert result["ok"] is True
sm.get_or_create.assert_awaited_once_with(
session_key="agent:kid-project:webchat:abc",
agent_id="kid-project",
display_name="WebChat",
)
@pytest.mark.asyncio
async def test_rpc_chat_auto_summarize_builds_provider_compaction_config() -> None:
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
sm = _FakeSessionManager(_history(6, 40))
sm._storage = SimpleNamespace(
get_session=AsyncMock(
return_value=SimpleNamespace(model="session/model", model_override="routed/model")
)
)
selector = _FakeProviderSelector()
ctx = SimpleNamespace(config=cfg, session_manager=sm, provider_selector=selector)
refusal = await _enforce_context_overflow(ctx, "s-auto", "m")
assert refusal is None
config = sm.compact_calls[0][2]
assert isinstance(config, CompactionConfig)
assert config.api_key == "overflow-provider-key"
assert config.model == "routed/model"
assert config.base_url == "https://openrouter.ai/api/v1"
assert selector.override_calls == []
assert selector.clone_instance.override_calls == ["routed/model"]
@pytest.mark.asyncio
async def test_auto_summarize_without_session_manager_uses_proxy() -> None:
"""Without a session manager, AUTO degrades to drop-oldest proxy."""
cfg = _cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10)
outcome = await apply_context_overflow_policy(
config=cfg,
message="m",
transcript=_history(6, 40),
session_key="s-proxy",
session_manager=None,
)
assert outcome.over_budget is True
assert outcome.retried is True
assert outcome.summarized is False
assert outcome.truncated_entries > 0
@pytest.mark.asyncio
async def test_outcome_carries_diagnostic_counters() -> None:
"""The returned OverflowOutcome exposes estimated + budget for observability."""
cfg = _cfg(ContextOverflowPolicy.REFUSE, budget=3)
outcome = await apply_context_overflow_policy(
config=cfg,
message="hello",
transcript=_history(2, 40),
session_key="s-x",
)
assert isinstance(outcome, OverflowOutcome)
assert outcome.estimated_tokens > outcome.budget_tokens
assert outcome.budget_tokens == 3