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

254 lines
9.0 KiB
Python

"""Tests for the compaction compaction public entry point.
: the dispatcher env-var, legacy implementation, and shadow comparator
have been deleted. ``compact_context`` delegates directly to
``compact_context_new``. These tests cover the public contract and the
turn-boundary cut behavior introduced in compaction.
"""
from __future__ import annotations
import pytest
from opensquilla.session.compaction import (
CompactionConfig,
CompactionRequest,
CompactionResult,
compact_context,
compact_context_new,
)
def _make_request(
entries: list[dict] | None = None,
window: int = 8192,
) -> CompactionRequest:
if entries is None:
entries = [
{"role": "user", "content": "hello", "token_count": 5},
{"role": "assistant", "content": "world", "token_count": 5},
]
return CompactionRequest(
session_id="dispatcher-test",
entries=entries,
context_window_tokens=window,
)
# ---------------------------------------------------------------------------
# compact_context delegates to compact_context_new
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_compact_context_returns_compaction_result():
"""compact_context returns a CompactionResult without raising."""
result = await compact_context(_make_request())
assert isinstance(result, CompactionResult)
@pytest.mark.asyncio
async def test_compact_context_delegates_to_new(monkeypatch):
"""compact_context calls compact_context_new (not any legacy path)."""
import opensquilla.session.compaction as compaction_mod
calls = []
async def spy_new(request):
calls.append(request)
return CompactionResult(
summary="spy",
kept_entries=[],
removed_count=0,
chunks_processed=0,
summary_source="skipped",
tokens_before=0,
tokens_after=0,
remaining_budget_tokens=8192,
)
monkeypatch.setattr(compaction_mod, "compact_context_new", spy_new)
result = await compact_context(_make_request())
assert result.summary == "spy"
assert len(calls) == 1
@pytest.mark.asyncio
async def test_compact_context_noop_when_within_budget():
"""When total tokens fit in the window, nothing is removed."""
result = await compact_context(_make_request(window=8192))
assert result.removed_count == 0
assert result.summary_source == "skipped"
# ---------------------------------------------------------------------------
# compact_context_new: turn-boundary cut
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_new_avoids_mid_turn_cut():
"""Turn-boundary cut must not split an assistant tool_call from its result."""
# Build entries that would be split mid-turn by a pure token-budget cut.
# Window = 30 tokens; keep_budget = 15.
# Entries: u=5 a_tool=5 tool_result=5 u=5 a=5 (total=25)
# A raw token-budget cut from the end would try to keep 15 tokens:
# a_final(5) + u_2(5) = 10 < 15, then tool_result(5) = 15 — keep 3 entries
# last_removed = a_tool → mid_turn!
# The new impl should walk back one more step so last_removed = u_1 (turn_boundary).
entries = [
{"role": "user", "content": "q1", "token_count": 5},
{
"role": "assistant",
"content": "[tool_call:read_file({\"path\": \"x\"})]",
"token_count": 5,
},
{"role": "tool", "content": "[tool_result:read_file] contents", "token_count": 5},
{"role": "user", "content": "q2", "token_count": 5},
{"role": "assistant", "content": "answer", "token_count": 5},
]
# Use a small window to force compaction; no API key so fallback summary is used.
request = CompactionRequest(
session_id="boundary-test",
entries=entries,
context_window_tokens=22, # total=25 > 22*safety(1.2)=26.4 — actually under; use tighter
config=CompactionConfig(safety_margin=1.0),
)
result = await compact_context_new(request)
if result.removed_count > 0:
# The cut should NOT land with last_removed = assistant tool_call
# and first_kept = tool_result.
removed = entries[: len(entries) - len(result.kept_entries)]
kept = result.kept_entries
if removed and kept:
last_removed_role = removed[-1].get("role")
last_removed_content = str(removed[-1].get("content") or "")
first_kept_role = kept[0].get("role")
is_mid_turn = (
last_removed_role == "assistant"
and "[tool_call:" in last_removed_content
and first_kept_role == "tool"
)
assert not is_mid_turn, (
f"Cut landed mid-turn: last_removed={removed[-1]}, first_kept={kept[0]}"
)
@pytest.mark.asyncio
async def test_new_avoids_mid_turn_cut_for_agent_flattened_tool_blocks():
"""Turn-boundary cut must match the Agent's flattened tool-use entries."""
entries = [
{"role": "user", "content": "old context", "token_count": 100},
{"role": "user", "content": "q1", "token_count": 100},
{"role": "assistant", "content": "[Used tool: read_file]", "token_count": 5},
{
"role": "user",
"content": "[Tool result (toolu_1): file contents]",
"token_count": 5,
},
{"role": "user", "content": "q2", "token_count": 5},
{"role": "assistant", "content": "answer", "token_count": 5},
]
request = CompactionRequest(
session_id="agent-flattened-boundary-test",
entries=entries,
context_window_tokens=100,
config=CompactionConfig(safety_margin=1.0),
)
result = await compact_context_new(request)
assert result.removed_count > 0
removed = entries[: len(entries) - len(result.kept_entries)]
kept = result.kept_entries
assert removed[-1]["content"] != "[Used tool: read_file]"
assert kept[0]["content"] == "[Used tool: read_file]"
@pytest.mark.asyncio
async def test_new_skips_when_only_cut_would_orphan_tool_result():
"""If no clean boundary exists, compaction must not split tool state."""
entries = [
{
"role": "assistant",
"content": "calling tool",
"tool_calls": [{"id": "call_1", "type": "function"}],
"token_count": 4,
},
{
"role": "tool",
"content": "tool result",
"tool_call_id": "call_1",
"token_count": 4,
},
{"role": "user", "content": "q2", "token_count": 3},
{"role": "assistant", "content": "answer", "token_count": 3},
]
request = CompactionRequest(
session_id="boundary-start-test",
entries=entries,
context_window_tokens=26,
config=CompactionConfig(safety_margin=1.0),
)
result = await compact_context_new(request)
assert result.removed_count == 0
assert result.summary_source == "skipped"
assert result.kept_entries == entries
assert result.skip_reason == "no_safe_turn_boundary"
@pytest.mark.asyncio
async def test_new_prev_summary_prefix_injected():
"""When custom_instructions carries __prev_summary__: the merged summary is prefixed."""
entries = [
{"role": "user", "content": "a " * 500, "token_count": 500},
{"role": "assistant", "content": "b " * 500, "token_count": 500},
]
request = CompactionRequest(
session_id="prev-summary-test",
entries=entries,
context_window_tokens=100,
config=CompactionConfig(safety_margin=1.0),
custom_instructions="__prev_summary__:prior context here\nnormal instructions",
)
result = await compact_context_new(request)
if result.removed_count > 0:
assert "[Previous context]" in result.summary
assert "prior context here" in result.summary
@pytest.mark.asyncio
async def test_new_returns_skipped_when_within_budget():
"""No compaction when tokens fit comfortably in the window."""
entries = [
{"role": "user", "content": "hi", "token_count": 5},
{"role": "assistant", "content": "hello", "token_count": 5},
]
request = CompactionRequest(
session_id="budget-test",
entries=entries,
context_window_tokens=8192,
)
result = await compact_context_new(request)
assert result.removed_count == 0
assert result.summary_source == "skipped"
assert result.kept_entries == entries
assert result.skip_reason == "within_compaction_budget"
@pytest.mark.asyncio
async def test_new_empty_entries():
"""Empty entries list returns a no-op skipped result."""
request = CompactionRequest(
session_id="empty-test",
entries=[],
context_window_tokens=8192,
)
result = await compact_context_new(request)
assert result.removed_count == 0
assert result.kept_entries == []
assert result.summary == ""
assert result.skip_reason == "no_entries"