项目文件夹

文件
2026-07-13 13:12:33 +08:00

1463 行
53 KiB
Python

"""Tests for SessionManager lifecycle operations."""
import contextlib
import json
import os
import sqlite3
import stat
from pathlib import Path
from typing import Any
import pytest
import pytest_asyncio
from opensquilla.session.compaction import CompactionConfig
from opensquilla.session.manager import SessionManager
from opensquilla.session.models import (
SessionContextState,
SessionIntent,
SessionStatus,
SessionSummary,
TranscriptEntry,
)
from opensquilla.session.storage import SessionStorage, StaleEpochError
@pytest_asyncio.fixture
async def manager():
storage = SessionStorage(":memory:")
await storage.connect()
mgr = SessionManager(storage, inject_time_prefix=False)
yield mgr
await storage.close()
@pytest.mark.asyncio
async def test_create_session(manager):
node = await manager.create("agent:main:main")
assert node.session_key == "agent:main:main"
assert node.status == SessionStatus.RUNNING
assert node.session_id is not None
@pytest.mark.asyncio
async def test_get_session_returns_existing_without_touching(manager):
node = await manager.create("agent:main:main")
fetched = await manager.get_session("agent:main:main")
missing = await manager.get_session("agent:main:missing")
assert fetched is not None
assert fetched.session_key == node.session_key
assert fetched.session_id == node.session_id
assert missing is None
@pytest.mark.asyncio
async def test_create_duplicate_raises(manager):
await manager.create("agent:main:main")
with pytest.raises(ValueError):
await manager.create("agent:main:main")
@pytest.mark.asyncio
async def test_get_or_create_creates(manager):
node, created = await manager.get_or_create("agent:main:main")
assert created is True
@pytest.mark.asyncio
async def test_get_or_create_returns_existing(manager):
await manager.create("agent:main:main")
node, created = await manager.get_or_create("agent:main:main")
assert created is False
@pytest.mark.asyncio
async def test_apply_intent_continue_preserves_existing_identity_and_transcript(manager):
node = await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "hello")
applied, rotated = await manager.apply_intent("agent:main:main", SessionIntent.CONTINUE)
assert rotated is False
assert applied.session_id == node.session_id
assert len(await manager.get_transcript("agent:main:main")) == 1
@pytest.mark.asyncio
async def test_apply_intent_new_chat_rejects_existing_key(manager):
await manager.create("agent:main:main")
with pytest.raises(ValueError, match="session_key conflict"):
await manager.apply_intent("agent:main:main", SessionIntent.NEW_CHAT)
@pytest.mark.asyncio
async def test_apply_intent_reset_same_key_rotates_identity_and_clears_state(
manager, tmp_path, monkeypatch
):
monkeypatch.setenv("OPENSQUILLA_SESSION_ARCHIVE_DIR", str(tmp_path / "archives"))
node = await manager.create("agent:main:main")
old_session_id = node.session_id
node.total_tokens = 123
node.input_tokens = 10
node.output_tokens = 20
node.estimated_cost_usd = 0.42
node.total_cost_usd = 0.42
node.billed_cost_usd = 0.30
node.estimated_cost_component_usd = 0.12
node.cost_source = "mixed"
node.missing_cost_entries = 1
node.cache_read = 7
node.cache_write = 8
await manager._storage.upsert_session(node)
await manager.append_message("agent:main:main", "user", "hello")
await manager._storage.save_summary(
SessionSummary(
session_id=old_session_id,
session_key="agent:main:main",
summary_text="old summary",
)
)
await manager.save_context_state(
SessionContextState(
session_id=old_session_id,
session_key="agent:main:main",
provider="portable",
state_kind="structured_summary_v1",
payload={"user_goal": "old task"},
covered_through_id=1,
valid=True,
)
)
applied, rotated = await manager.apply_intent("agent:main:main", SessionIntent.RESET_SAME_KEY)
assert rotated is True
assert applied.session_key == "agent:main:main"
assert applied.session_id != old_session_id
assert await manager._storage.count_transcript_entries(old_session_id) == 0
assert await manager._storage.count_transcript_entries(applied.session_id) == 0
assert await manager._storage.get_all_summaries(old_session_id) == []
assert await manager.get_context_states("agent:main:main") == []
invalidated_states = await manager.get_context_states("agent:main:main", valid_only=False)
assert len(invalidated_states) == 1
assert invalidated_states[0].valid is False
assert invalidated_states[0].invalid_reason == "session_reset"
assert applied.total_tokens == 0
assert applied.input_tokens == 0
assert applied.output_tokens == 0
assert applied.estimated_cost_usd == 0.0
assert applied.total_cost_usd == 0.0
assert applied.billed_cost_usd == 0.0
assert applied.estimated_cost_component_usd == 0.0
assert applied.cost_source == "none"
assert applied.missing_cost_entries == 0
assert applied.cache_read == 0
assert applied.cache_write == 0
archive_files = list((tmp_path / "archives").glob("*.json"))
assert len(archive_files) == 1
archived = json.loads(archive_files[0].read_text(encoding="utf-8"))
assert archived["session_key"] == "agent:main:main"
assert archived["session_id"] == old_session_id
assert archived["transcript_entries"][0]["content"] == "hello"
assert archived["summaries"][0]["summary_text"] == "old summary"
@pytest.mark.asyncio
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission semantics only")
async def test_reset_archive_is_owner_only(manager, tmp_path, monkeypatch):
archive_root = tmp_path / "session-archive"
monkeypatch.setenv("OPENSQUILLA_SESSION_ARCHIVE_DIR", str(archive_root))
old_umask = os.umask(0o022)
try:
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "hello")
_, rotated = await manager.apply_intent("agent:main:main", SessionIntent.RESET_SAME_KEY)
assert rotated is True
finally:
os.umask(old_umask)
archives = list(archive_root.glob("*.json"))
assert len(archives) == 1
# The archive holds the full raw transcript, so it must match the
# sessions.db hardening (0600 file inside a 0700 directory).
assert stat.S_IMODE(archive_root.stat().st_mode) & 0o077 == 0
assert stat.S_IMODE(archives[0].stat().st_mode) & 0o077 == 0
@pytest.mark.asyncio
async def test_reset_same_key_fences_appends_that_read_the_old_epoch(
manager, tmp_path, monkeypatch
):
monkeypatch.setenv("OPENSQUILLA_SESSION_ARCHIVE_DIR", str(tmp_path / "archives"))
node = await manager.create("agent:main:main")
pre_epoch = await manager._storage.get_epoch("agent:main:main")
applied, rotated = await manager.apply_intent("agent:main:main", SessionIntent.RESET_SAME_KEY)
assert rotated is True
# The rotation itself bumps the epoch, so a writer holding the pre-reset
# node cannot land its entry (or roll back the reset via a stale upsert).
assert await manager._storage.get_epoch("agent:main:main") > pre_epoch
with pytest.raises(StaleEpochError):
await manager._storage.append_transcript_entry(
TranscriptEntry(
session_id=node.session_id,
session_key="agent:main:main",
role="user",
content="stale in-flight message",
),
expected_epoch=pre_epoch,
)
row = await manager._storage.get_session("agent:main:main")
assert row is not None
assert row.session_id == applied.session_id
assert [e.content for e in await manager.get_transcript("agent:main:main")] == []
@pytest.mark.asyncio
async def test_reset_same_key_archive_preserves_compacted_canonical_transcript(
manager, tmp_path, monkeypatch
):
monkeypatch.setenv("OPENSQUILLA_SESSION_ARCHIVE_DIR", str(tmp_path / "archives"))
node = await manager.create("agent:main:main")
old_session_id = node.session_id
for index in range(4):
await manager.append_message("agent:main:main", "user", f"msg {index}", token_count=5)
await manager.persist_compaction_result(
"agent:main:main",
"short summary",
[{"role": "assistant", "content": "latest reply"}],
compaction_id="cmp_reset_archive",
)
canonical_before_reset = [
entry.content for entry in await manager.get_canonical_transcript("agent:main:main")
]
applied, rotated = await manager.apply_intent("agent:main:main", SessionIntent.RESET_SAME_KEY)
assert rotated is True
assert applied.session_id != old_session_id
archive_files = list((tmp_path / "archives").glob("*.json"))
assert len(archive_files) == 1
archived = json.loads(archive_files[0].read_text(encoding="utf-8"))
assert [entry["content"] for entry in archived["transcript_entries"]] == (
canonical_before_reset
)
assert archived["summaries"][0]["compaction_id"] == "cmp_reset_archive"
@pytest.mark.asyncio
async def test_apply_intent_reset_same_key_missing_creates_session(manager):
applied, rotated = await manager.apply_intent(
"agent:main:missing", SessionIntent.RESET_SAME_KEY
)
assert rotated is True
assert applied.session_key == "agent:main:missing"
assert applied.session_id
@pytest.mark.asyncio
async def test_apply_intent_reset_same_key_archive_failure_does_not_block(
manager, tmp_path, monkeypatch
):
archive_file = tmp_path / "not-a-directory"
archive_file.write_text("occupied", encoding="utf-8")
monkeypatch.setenv("OPENSQUILLA_SESSION_ARCHIVE_DIR", str(archive_file))
node = await manager.create("agent:main:main")
old_session_id = node.session_id
await manager.append_message("agent:main:main", "user", "hello")
applied, rotated = await manager.apply_intent("agent:main:main", SessionIntent.RESET_SAME_KEY)
assert rotated is True
assert applied.session_id != old_session_id
assert await manager._storage.count_transcript_entries(old_session_id) == 0
@pytest.mark.asyncio
async def test_resume_touches_updated_at(manager):
node = await manager.create("agent:main:main")
old_ts = node.updated_at
import asyncio
await asyncio.sleep(0.01)
resumed = await manager.resume("agent:main:main")
assert resumed.updated_at >= old_ts
@pytest.mark.asyncio
async def test_resume_missing_raises(manager):
with pytest.raises(KeyError):
await manager.resume("agent:main:nope")
@pytest.mark.asyncio
async def test_update_fields(manager):
await manager.create("agent:main:main")
updated = await manager.update("agent:main:main", model="claude-opus-4-6", channel="telegram")
assert updated.model == "claude-opus-4-6"
assert updated.channel == "telegram"
@pytest.mark.asyncio
async def test_finish_sets_status(manager):
await manager.create("agent:main:main")
node = await manager.finish("agent:main:main")
assert node.status == SessionStatus.DONE
assert node.ended_at is not None
assert node.runtime_ms is not None
@pytest.mark.asyncio
async def test_finish_failed(manager):
await manager.create("agent:main:main")
node = await manager.finish("agent:main:main", status=SessionStatus.FAILED)
assert node.status == SessionStatus.FAILED
@pytest.mark.asyncio
async def test_append_message(manager):
await manager.create("agent:main:main")
entry = await manager.append_message("agent:main:main", "user", "Hello!")
assert entry.role == "user"
assert entry.content == "Hello!"
@pytest.mark.asyncio
async def test_append_message_updates_tokens(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "hi", token_count=10)
node = await manager._storage.get_session("agent:main:main")
assert node.total_tokens == 10
@pytest.mark.asyncio
async def test_append_message_with_turn_usage_does_not_double_count_output_tokens(manager):
await manager.create("agent:main:main")
await manager.append_message(
"agent:main:main",
"assistant",
"hi",
turn_usage={"model": "gpt-test", "input_tokens": 10, "output_tokens": 3},
token_count=3,
)
node = await manager._storage.get_session("agent:main:main")
assert node.total_tokens == 0
@pytest.mark.asyncio
async def test_get_transcript(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "msg1")
await manager.append_message("agent:main:main", "assistant", "resp1")
entries = await manager.get_transcript("agent:main:main")
assert len(entries) == 2
@pytest.mark.asyncio
async def test_get_transcript_orders_same_timestamp_by_insert_id(manager):
node = await manager.create("agent:main:main")
for content in ("first", "second", "third"):
await manager._storage.append_transcript_entry(
TranscriptEntry(
session_id=node.session_id,
session_key=node.session_key,
role="user",
content=content,
created_at=12345,
)
)
entries = await manager.get_transcript("agent:main:main")
assert [entry.content for entry in entries] == ["first", "second", "third"]
assert [entry.id for entry in entries] == sorted(entry.id for entry in entries)
def test_get_transcript_query_uses_id_tiebreaker() -> None:
source = Path("src/opensquilla/session/storage.py").read_text(encoding="utf-8")
assert "ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?" in source
@pytest.mark.asyncio
async def test_truncate_zero_removes_all_entries(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "msg1")
await manager.append_message("agent:main:main", "assistant", "resp1")
result = await manager.truncate("agent:main:main", max_messages=0)
assert result == {"truncated": True, "before_count": 2, "after_count": 0}
assert await manager.get_transcript("agent:main:main") == []
@pytest.mark.asyncio
async def test_branch_creates_child(manager):
await manager.create("agent:main:main")
child = await manager.branch("agent:main:main", "agent:main:direct:u1")
assert child.parent_session_key == "agent:main:main"
assert child.spawn_depth == 1
@pytest.mark.asyncio
async def test_branch_fork_transcript(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "parent msg", token_count=5)
await manager.append_message(
"agent:main:main",
"assistant",
"parent reply",
turn_usage={"model": "openai/gpt-test", "input_tokens": 11, "output_tokens": 5},
token_count=5,
)
child = await manager.branch("agent:main:main", "agent:main:direct:u1", fork_transcript=True)
assert child.forked_from_parent is True
entries = await manager.get_transcript("agent:main:direct:u1")
assert len(entries) == 2
assert entries[0].content == "parent msg"
assert entries[1].turn_usage == {
"model": "openai/gpt-test",
"input_tokens": 11,
"output_tokens": 5,
}
@pytest.mark.asyncio
async def test_branch_before_message_copies_only_prefix(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "A marker", token_count=5)
await manager.append_message("agent:main:main", "assistant", "ack A", token_count=5)
before_entry = await manager.append_message(
"agent:main:main", "user", "B marker", token_count=5
)
await manager.append_message("agent:main:main", "assistant", "ack B", token_count=5)
await manager.append_message("agent:main:main", "user", "C marker must not leak", token_count=5)
child = await manager.branch(
"agent:main:main",
"agent:main:direct:edited",
fork_transcript=True,
fork_before_message_id=before_entry.message_id,
)
assert child.forked_from_parent is True
child_contents = [
entry.content for entry in await manager.get_transcript("agent:main:direct:edited")
]
assert child_contents == [
"A marker",
"ack A",
]
assert [entry.content for entry in await manager.get_transcript("agent:main:main")] == [
"A marker",
"ack A",
"B marker",
"ack B",
"C marker must not leak",
]
assert await manager.get_summaries("agent:main:direct:edited") == []
assert await manager.get_context_states("agent:main:direct:edited") == []
@pytest.mark.asyncio
async def test_branch_before_message_missing_id_does_not_create_child(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "A marker")
with pytest.raises(KeyError, match="Transcript message not found"):
await manager.branch(
"agent:main:main",
"agent:main:direct:missing",
fork_transcript=True,
fork_before_message_id="missing-message-id",
)
assert await manager.get_session("agent:main:direct:missing") is None
@pytest.mark.asyncio
async def test_branch_fork_transcript_copies_compaction_summaries(manager):
parent = await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "kept tail", token_count=5)
await manager._storage.save_summary(
SessionSummary(
session_id=parent.session_id,
session_key="agent:main:main",
compaction_id="cmp_branch_1",
trigger_reason="preflight_compaction",
summary_text="older compacted context",
summary_payload={"user_goal": "continue the parent task"},
summary_format="structured_v1",
summary_source="llm",
coverage_status="pass",
missing_obligations=["none"],
critical_carry_forward=["remember file path"],
tokens_before=1000,
tokens_after=300,
removed_count=3,
kept_count=1,
chunk_count=2,
flush_receipt_status="safe",
covered_through_id=123,
)
)
await manager.save_context_state(
SessionContextState(
session_id=parent.session_id,
session_key="agent:main:main",
provider="portable",
state_kind="structured_summary_v1",
payload={"user_goal": "continue the parent task"},
covered_through_id=123,
portable=True,
cacheable=True,
)
)
child = await manager.branch("agent:main:main", "agent:main:direct:u1", fork_transcript=True)
assert child.forked_from_parent is True
child_summaries = await manager.get_summaries("agent:main:direct:u1")
assert len(child_summaries) == 1
assert child_summaries[0].summary_text == "older compacted context"
assert child_summaries[0].compaction_id == "cmp_branch_1"
assert child_summaries[0].trigger_reason == "preflight_compaction"
assert child_summaries[0].summary_payload == {"user_goal": "continue the parent task"}
assert child_summaries[0].summary_format == "structured_v1"
assert child_summaries[0].summary_source == "llm"
assert child_summaries[0].coverage_status == "pass"
assert child_summaries[0].missing_obligations == ["none"]
assert child_summaries[0].critical_carry_forward == ["remember file path"]
assert child_summaries[0].tokens_before == 1000
assert child_summaries[0].tokens_after == 300
assert child_summaries[0].removed_count == 3
assert child_summaries[0].kept_count == 1
assert child_summaries[0].chunk_count == 2
assert child_summaries[0].flush_receipt_status == "safe"
assert child_summaries[0].covered_through_id == 123
assert child_summaries[0].session_id == child.session_id
assert child_summaries[0].session_key == "agent:main:direct:u1"
child_states = await manager.get_context_states("agent:main:direct:u1")
assert len(child_states) == 1
assert child_states[0].session_id == child.session_id
assert child_states[0].session_key == "agent:main:direct:u1"
assert child_states[0].payload == {"user_goal": "continue the parent task"}
assert child_states[0].portable is True
assert child_states[0].cacheable is True
@pytest.mark.asyncio
async def test_branch_fork_transcript_copies_compacted_archive(manager):
await manager.create("agent:main:main")
for index in range(4):
await manager.append_message("agent:main:main", "user", f"msg {index}", token_count=5)
await manager.persist_compaction_result(
"agent:main:main",
"short summary",
[{"role": "assistant", "content": "latest reply"}],
compaction_id="cmp_branch_archive",
trigger_reason="agent_inline_overflow",
)
parent_canonical = [
entry.content for entry in await manager.get_canonical_transcript("agent:main:main")
]
child = await manager.branch("agent:main:main", "agent:main:direct:u1", fork_transcript=True)
assert child.parent_session_key == "agent:main:main"
assert [entry.content for entry in await manager.get_transcript("agent:main:direct:u1")] == [
"latest reply"
]
assert [
entry.content for entry in await manager.get_canonical_transcript("agent:main:direct:u1")
] == parent_canonical
child_summaries = await manager.get_summaries("agent:main:direct:u1")
assert child_summaries[0].compaction_id == "cmp_branch_archive"
assert child_summaries[0].trigger_reason == "agent_inline_overflow"
@pytest.mark.asyncio
async def test_context_state_roundtrip_and_invalidate(manager):
node = await manager.create("agent:main:main")
state = await manager.save_context_state(
SessionContextState(
session_id=node.session_id,
session_key="agent:main:main",
provider="portable",
model=None,
state_kind="structured_summary_v1",
payload={"current_status": "summary state"},
covered_through_id=42,
portable=True,
cacheable=True,
)
)
loaded = await manager.get_context_states("agent:main:main")
assert state.id is not None
assert len(loaded) == 1
assert loaded[0].payload == {"current_status": "summary state"}
assert loaded[0].portable is True
assert loaded[0].cacheable is True
assert loaded[0].valid is True
invalidated = await manager.invalidate_context_states(
"agent:main:main",
state_kind="structured_summary_v1",
reason="provider switched",
)
assert invalidated == 1
assert await manager.get_context_states("agent:main:main") == []
invalid = await manager.get_context_states("agent:main:main", valid_only=False)
assert len(invalid) == 1
assert invalid[0].valid is False
assert invalid[0].invalid_reason == "provider switched"
@pytest.mark.asyncio
async def test_storage_migrates_legacy_summary_metadata_columns(tmp_path):
db_path = tmp_path / "legacy.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"""
CREATE TABLE session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
session_key TEXT NOT NULL,
compaction_index INTEGER NOT NULL DEFAULT 0,
summary_text TEXT NOT NULL,
covered_through_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 1
)
"""
)
conn.execute(
"""
INSERT INTO session_summaries (
session_id, session_key, compaction_index, summary_text,
covered_through_id, created_at, schema_version
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
("legacy-session", "agent:main:legacy", 0, "legacy summary", 7, 123, 1),
)
storage = SessionStorage(str(db_path))
await storage.connect()
try:
async with storage.conn.execute("PRAGMA table_info(session_summaries)") as cur:
columns = {row[1] for row in await cur.fetchall()}
assert {
"compaction_id",
"trigger_reason",
"summary_payload",
"summary_format",
"summary_source",
"coverage_status",
"missing_obligations",
"critical_carry_forward",
"tokens_before",
"tokens_after",
"removed_count",
"kept_count",
"chunk_count",
"flush_receipt_status",
}.issubset(columns)
summary = await storage.get_latest_summary("legacy-session")
assert summary is not None
assert summary.summary_text == "legacy summary"
assert summary.summary_payload is None
assert summary.summary_format == "text"
assert summary.summary_source == "unknown"
assert summary.coverage_status == "unknown"
assert summary.missing_obligations is None
assert summary.critical_carry_forward is None
assert summary.compaction_id is None
assert summary.trigger_reason is None
assert summary.tokens_before is None
assert summary.tokens_after is None
assert summary.removed_count == 0
assert summary.kept_count == 0
assert summary.chunk_count == 0
assert summary.flush_receipt_status == "unknown"
finally:
await storage.close()
@pytest.mark.asyncio
async def test_storage_migrates_session_context_state_table(tmp_path):
db_path = tmp_path / "legacy-context-state.db"
with sqlite3.connect(db_path) as conn:
conn.execute("CREATE TABLE sessions (session_key TEXT PRIMARY KEY)")
storage = SessionStorage(str(db_path))
await storage.connect()
try:
async with storage.conn.execute("PRAGMA table_info(session_context_states)") as cur:
columns = {row[1] for row in await cur.fetchall()}
assert {
"session_id",
"session_key",
"provider",
"model",
"state_kind",
"payload",
"covered_through_id",
"created_at",
"expires_at",
"portable",
"cacheable",
"valid",
"invalid_reason",
}.issubset(columns)
finally:
await storage.close()
@pytest.mark.asyncio
async def test_branch_fork_skipped_if_over_budget(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "msg", token_count=1000)
child = await manager.branch(
"agent:main:main", "agent:main:direct:u1", fork_transcript=True, max_fork_tokens=10
)
assert child.forked_from_parent is False
@pytest.mark.asyncio
async def test_branch_fork_budget_counts_compaction_summaries(manager):
parent = await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "kept", token_count=1)
await manager._storage.save_summary(
SessionSummary(
session_id=parent.session_id,
session_key="agent:main:main",
summary_text="x" * 400,
)
)
child = await manager.branch(
"agent:main:main",
"agent:main:direct:u1",
fork_transcript=True,
max_fork_tokens=10,
)
assert child.forked_from_parent is False
assert await manager.get_transcript("agent:main:direct:u1") == []
assert await manager.get_summaries("agent:main:direct:u1") == []
@pytest.mark.asyncio
async def test_compact_no_op_small_context(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "hi", token_count=5)
summary = await manager.compact("agent:main:main", context_window_tokens=100_000)
assert summary == ""
@pytest.mark.asyncio
async def test_compact_reduces_transcript(manager):
await manager.create("agent:main:main")
# Add many large messages
for i in range(20):
await manager.append_message("agent:main:main", "user", "x" * 500, token_count=200)
summary = await manager.compact("agent:main:main", context_window_tokens=1000)
assert summary != ""
node = await manager._storage.get_session("agent:main:main")
assert node.compaction_count == 1
transcript = await manager.get_transcript("agent:main:main")
assert transcript
assert all(entry.role != "system" for entry in transcript)
summaries = await manager._storage.get_all_summaries(node.session_id)
assert len(summaries) == 1
@pytest.mark.asyncio
async def test_compact_with_result_returns_source_and_persists(manager):
await manager.create("agent:main:main")
for i in range(20):
await manager.append_message(
"agent:main:main",
"user",
f"msg {i} " + ("x" * 500),
token_count=200,
)
original_contents = [entry.content for entry in await manager.get_transcript("agent:main:main")]
result = await manager.compact_with_result("agent:main:main", context_window_tokens=1000)
assert result.summary
assert result.summary_source == "fallback"
node = await manager._storage.get_session("agent:main:main")
assert node.compaction_count == 1
transcript = await manager.get_transcript("agent:main:main")
assert transcript
assert all(entry.role != "system" for entry in transcript)
summaries = await manager.get_summaries("agent:main:main")
assert [summary.summary_text for summary in summaries] == [result.summary]
assert summaries[0].summary_format == "structured_v1"
assert summaries[0].summary_source == result.summary_source
assert summaries[0].coverage_status in {"unknown", "pass", "pass_with_backfill"}
assert summaries[0].summary_payload is not None
assert summaries[0].summary_payload["schema_version"] == 1
assert summaries[0].compaction_id
assert summaries[0].tokens_before == result.tokens_before
assert summaries[0].tokens_after == result.tokens_after
assert summaries[0].removed_count == result.removed_count
assert summaries[0].kept_count == len(result.kept_entries)
assert summaries[0].chunk_count == result.chunks_processed
canonical_contents = [
entry.content for entry in await manager.get_canonical_transcript("agent:main:main")
]
assert canonical_contents == original_contents
assert [entry.content for entry in transcript] == original_contents[-len(transcript) :]
@pytest.mark.asyncio
async def test_compact_with_result_skips_rewrite_when_transcript_changes(manager):
await manager.create("agent:main:main")
for i in range(20):
await manager.append_message(
"agent:main:main",
"user",
f"msg {i} " + ("x" * 500),
token_count=200,
)
original_contents = [entry.content for entry in await manager.get_transcript("agent:main:main")]
context_entries = 0
@contextlib.asynccontextmanager
async def mutation_context():
nonlocal context_entries
context_entries += 1
if context_entries == 2:
await manager.append_message(
"agent:main:main",
"user",
"late queued followup",
token_count=3,
)
yield
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=1000,
mutation_context=mutation_context,
)
assert result.summary == ""
assert result.summary_source == "skipped"
assert result.skip_reason == "stale_preimage"
assert result.removed_count == 0
node = await manager._storage.get_session("agent:main:main")
assert node.compaction_count == 0
assert await manager.get_summaries("agent:main:main") == []
transcript = await manager.get_transcript("agent:main:main")
assert [entry.content for entry in transcript] == original_contents + ["late queued followup"]
@pytest.mark.asyncio
async def test_compact_with_result_marks_unsafe_receipt_as_degraded_forensic(manager):
await manager.create("agent:main:main")
for i in range(20):
await manager.append_message(
"agent:main:main",
"user",
f"unsafe msg {i} " + ("x" * 500),
token_count=200,
)
original_contents = [entry.content for entry in await manager.get_transcript("agent:main:main")]
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=1000,
flush_receipt_status="unsafe",
)
assert result.removed_count > 0
summaries = await manager.get_summaries("agent:main:main")
assert summaries[0].flush_receipt_status == "degraded_forensic"
canonical_contents = [
entry.content for entry in await manager.get_canonical_transcript("agent:main:main")
]
assert canonical_contents == original_contents
@pytest.mark.asyncio
async def test_degraded_compaction_preimage_can_be_listed_for_repair(manager):
await manager.create("agent:main:main")
for i in range(20):
await manager.append_message(
"agent:main:main",
"user",
f"repair msg {i} " + ("x" * 500),
token_count=200,
)
await manager.compact_with_result(
"agent:main:main",
context_window_tokens=1000,
flush_receipt_status="degraded_forensic",
)
pending = await manager.list_degraded_compactions(agent_id="main")
assert len(pending) == 1
assert pending[0].flush_receipt_status == "degraded_forensic"
preimage = await manager.get_compaction_preimage(pending[0])
assert preimage
assert preimage[0].content.startswith("repair msg 0")
await manager.mark_compaction_repair_status(pending[0], "repaired")
assert await manager.list_degraded_compactions(agent_id="main") == []
@pytest.mark.asyncio
async def test_compaction_flush_status_can_be_backfilled_by_compaction_id(manager):
await manager.create("agent:main:main")
for i in range(20):
await manager.append_message(
"agent:main:main",
"user",
f"background flush msg {i} " + ("x" * 500),
token_count=200,
)
await manager.compact_with_result(
"agent:main:main",
context_window_tokens=1000,
compaction_id="cmp-bg-flush",
flush_receipt_status="degraded_forensic",
)
updated = await manager.mark_compaction_flush_receipt_status(
"agent:main:main",
"cmp-bg-flush",
"safe",
)
assert updated == 1
summaries = await manager.get_summaries("agent:main:main")
assert summaries[0].flush_receipt_status == "safe"
assert await manager.list_degraded_compactions(agent_id="main") == []
@pytest.mark.asyncio
async def test_noop_memory_flush_compaction_status_does_not_enter_repair_queue(manager):
await manager.create("agent:main:main")
for i in range(20):
await manager.append_message(
"agent:main:main",
"user",
f"noop msg {i} " + ("x" * 500),
token_count=200,
)
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=1000,
flush_receipt_status="noop_no_memory",
)
assert result.removed_count > 0
summaries = await manager.get_summaries("agent:main:main")
assert summaries[0].flush_receipt_status == "noop_no_memory"
assert await manager.list_degraded_compactions(agent_id="main") == []
@pytest.mark.asyncio
async def test_archive_only_memory_flush_compaction_status_does_not_enter_repair_queue(manager):
await manager.create("agent:main:main")
for i in range(20):
await manager.append_message(
"agent:main:main",
"user",
f"archive msg {i} " + ("x" * 500),
token_count=200,
)
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=1000,
flush_receipt_status="archive_only",
)
assert result.removed_count > 0
summaries = await manager.get_summaries("agent:main:main")
assert summaries[0].flush_receipt_status == "archive_only"
assert await manager.list_degraded_compactions(agent_id="main") == []
@pytest.mark.asyncio
async def test_compact_with_result_reports_and_backfills_missing_obligations(manager):
await manager.create("agent:main:main")
await manager.append_message(
"agent:main:main",
"user",
(
"Goal: finish continuity work.\n"
"Constraint: do not enable coverage blocking by default.\n"
"Keep src/opensquilla/session/models.py and docs/Long Task Report.md."
),
token_count=250,
)
await manager.append_message(
"agent:main:main",
"assistant",
"Next I will run uv run pytest tests/test_session/test_manager.py.",
tool_calls=[{"id": "call_exec_1", "type": "function"}],
token_count=250,
)
await manager.append_message(
"agent:main:main",
"tool",
(
"Command failed: uv run pytest tests/test_session/test_manager.py\n"
"Exit code 1\n"
"Error: missing summary_payload column"
),
tool_call_id="call_exec_1",
token_count=250,
)
for i in range(8):
await manager.append_message(
"agent:main:main",
"assistant",
f"recent tail {i}",
token_count=20,
)
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=500,
config=CompactionConfig(safety_margin=1.0),
)
assert result.removed_count > 0
summaries = await manager.get_summaries("agent:main:main")
summary = summaries[0]
assert summary.summary_text == result.summary
assert summary.summary_format == "structured_v1"
assert summary.summary_payload is not None
assert summary.coverage_status == "pass_with_backfill"
assert summary.missing_obligations
assert summary.critical_carry_forward
assert "src/opensquilla/session/models.py" in str(summary.summary_payload)
assert any("call_exec_1" in item for item in summary.critical_carry_forward)
assert await manager.get_transcript("agent:main:main")
@pytest.mark.asyncio
async def test_compact_with_result_strict_coverage_blocks_destructive_rewrite(manager):
node = await manager.create("agent:main:main")
late_critical_path = "src/opensquilla/session/critical_continuity.py"
await manager.append_message(
"agent:main:main",
"user",
"Goal: preserve strict continuity. " + ("padding " * 40) + late_critical_path,
token_count=650,
)
for index in range(4):
await manager.append_message(
"agent:main:main",
"assistant",
f"recent tail {index}",
token_count=20,
)
original_transcript = await manager.get_transcript("agent:main:main")
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=300,
config=CompactionConfig(safety_margin=1.0, coverage_blocking=True),
)
assert result.removed_count == 0
assert result.skip_reason == "coverage_blocked"
assert result.coverage_status == "fail_blocked"
assert any(late_critical_path in item for item in result.missing_obligations or [])
assert await manager.get_transcript("agent:main:main") == original_transcript
assert await manager.get_summaries("agent:main:main") == []
assert await manager.get_context_states("agent:main:main") == []
current_node = await manager._storage.get_session("agent:main:main")
assert current_node is not None
assert current_node.session_id == node.session_id
assert current_node.compaction_count == 0
@pytest.mark.asyncio
async def test_compact_with_result_writes_portable_context_state(manager):
await manager.create("agent:main:main")
await manager.append_message(
"agent:main:main",
"user",
"Goal: keep portable state. File src/opensquilla/session/models.py.",
token_count=250,
)
for i in range(8):
await manager.append_message(
"agent:main:main",
"assistant",
f"recent tail {i}",
token_count=20,
)
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=300,
config=CompactionConfig(safety_margin=1.0),
)
states = await manager.get_context_states("agent:main:main")
assert result.removed_count > 0
assert len(states) == 1
state = states[0]
assert state.provider == "portable"
assert state.model is None
assert state.state_kind == "structured_summary_v1"
summaries = await manager.get_summaries("agent:main:main")
assert summaries[0].compaction_id
payload_without_correlation = dict(state.payload)
assert payload_without_correlation.pop("compaction_id") == summaries[0].compaction_id
assert payload_without_correlation == result.summary_payload
assert state.covered_through_id > 0
assert state.portable is True
assert state.cacheable is True
assert state.valid is True
assert await manager.get_transcript("agent:main:main")
@pytest.mark.asyncio
async def test_compact_with_result_preserves_tool_metadata_for_boundary_cut(manager):
await manager.create("agent:main:main")
await manager.append_message("agent:main:main", "user", "old context", token_count=300)
await manager.append_message(
"agent:main:main",
"assistant",
"calling tool",
tool_calls=[{"id": "call_1", "type": "function"}],
token_count=4,
)
await manager.append_message(
"agent:main:main",
"tool",
"tool result",
tool_call_id="call_1",
token_count=4,
)
await manager.append_message("agent:main:main", "user", "next question", token_count=3)
await manager.append_message("agent:main:main", "assistant", "answer", token_count=3)
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=100,
config=CompactionConfig(safety_margin=1.0),
)
assert result.removed_count == 1
assert result.kept_entries[0]["role"] == "assistant"
assert result.kept_entries[0]["tool_calls"] == [{"id": "call_1", "type": "function"}]
transcript = await manager.get_transcript("agent:main:main")
assert transcript[0].role == "assistant"
assert transcript[0].tool_calls == [{"id": "call_1", "type": "function"}]
assert transcript[1].role == "tool"
assert transcript[1].tool_call_id == "call_1"
@pytest.mark.asyncio
async def test_compact_counts_tool_calls_when_token_count_is_underreported(manager):
await manager.create("agent:main:main")
large_content = "x" * 5000
await manager.append_message(
"agent:main:main",
"assistant",
"small visible answer",
tool_calls=[
{
"type": "tool_use",
"tool_use_id": "write-stale",
"name": "write_file",
"input": {"path": "index.html", "content": large_content},
}
],
token_count=1,
)
result = await manager.compact_with_result(
"agent:main:main",
context_window_tokens=50,
config=CompactionConfig(safety_margin=1.0),
)
assert result.removed_count == 1
assert result.summary
assert large_content not in result.summary
node = await manager._storage.get_session("agent:main:main")
assert node.compaction_count == 1
def _fail_next_transcript_insert(monkeypatch: pytest.MonkeyPatch, storage: SessionStorage) -> None:
original_execute = storage.conn.execute
failed = False
def execute(sql: str, params: Any = ()):
nonlocal failed
if (
not failed
and isinstance(sql, str)
and sql.lstrip().upper().startswith("INSERT INTO TRANSCRIPT_ENTRIES")
):
failed = True
raise RuntimeError("rewrite insert failed")
return original_execute(sql, params)
monkeypatch.setattr(storage.conn, "execute", execute)
@pytest.mark.asyncio
async def test_compact_rewrite_failure_keeps_session_state_atomic(
manager,
monkeypatch: pytest.MonkeyPatch,
):
node = await manager.create("agent:main:main")
for index in range(20):
await manager.append_message("agent:main:main", "user", f"msg {index} " + ("x" * 500))
original_transcript = await manager.get_transcript("agent:main:main")
original_canonical_transcript = await manager.get_canonical_transcript("agent:main:main")
original_summaries = await manager.get_summaries("agent:main:main")
original_node = await manager._storage.get_session("agent:main:main")
_fail_next_transcript_insert(monkeypatch, manager._storage)
with pytest.raises(RuntimeError, match="rewrite insert failed"):
await manager.compact("agent:main:main", context_window_tokens=1000)
assert await manager.get_transcript("agent:main:main") == original_transcript
assert (
await manager.get_canonical_transcript("agent:main:main") == original_canonical_transcript
)
assert await manager.get_summaries("agent:main:main") == original_summaries
assert await manager.get_context_states("agent:main:main") == []
current_node = await manager._storage.get_session("agent:main:main")
assert current_node is not None
assert original_node is not None
assert current_node.session_id == node.session_id
assert current_node.compaction_count == original_node.compaction_count
assert current_node.updated_at == original_node.updated_at
@pytest.mark.asyncio
async def test_persist_compaction_result_rewrite_failure_keeps_session_state_atomic(
manager,
monkeypatch: pytest.MonkeyPatch,
):
node = await manager.create("agent:main:main")
for index in range(4):
await manager.append_message("agent:main:main", "user", f"msg {index}", token_count=5)
original_transcript = await manager.get_transcript("agent:main:main")
original_canonical_transcript = await manager.get_canonical_transcript("agent:main:main")
original_summaries = await manager.get_summaries("agent:main:main")
original_node = await manager._storage.get_session("agent:main:main")
_fail_next_transcript_insert(monkeypatch, manager._storage)
with pytest.raises(RuntimeError, match="rewrite insert failed"):
await manager.persist_compaction_result(
"agent:main:main",
"short summary",
[{"role": "assistant", "content": "latest reply"}],
)
assert await manager.get_transcript("agent:main:main") == original_transcript
assert (
await manager.get_canonical_transcript("agent:main:main") == original_canonical_transcript
)
assert await manager.get_summaries("agent:main:main") == original_summaries
assert await manager.get_context_states("agent:main:main") == []
current_node = await manager._storage.get_session("agent:main:main")
assert current_node is not None
assert original_node is not None
assert current_node.session_id == node.session_id
assert current_node.compaction_count == original_node.compaction_count
assert current_node.updated_at == original_node.updated_at
@pytest.mark.asyncio
async def test_persist_compaction_result_stores_summary_out_of_band(manager):
node = await manager.create("agent:main:main")
for index in range(4):
await manager.append_message("agent:main:main", "user", f"msg {index}", token_count=5)
await manager.persist_compaction_result(
"agent:main:main",
"short summary",
[{"role": "assistant", "content": "latest reply"}],
compaction_id="cmp_inline_1",
trigger_reason="agent_inline_overflow",
)
transcript = await manager.get_transcript("agent:main:main")
assert all(entry.role != "system" for entry in transcript)
assert transcript[-1].content == "latest reply"
canonical = await manager.get_canonical_transcript("agent:main:main")
assert [entry.content for entry in canonical] == [
"msg 0",
"msg 1",
"msg 2",
"latest reply",
]
async with manager._storage.conn.execute(
"SELECT compaction_id, compaction_index FROM compacted_transcript_entries "
"WHERE session_id = ? ORDER BY original_entry_id ASC",
(node.session_id,),
) as cur:
archived_rows = await cur.fetchall()
assert [(row[0], row[1]) for row in archived_rows] == [
("cmp_inline_1", 0),
("cmp_inline_1", 0),
("cmp_inline_1", 0),
]
summaries = await manager._storage.get_all_summaries(node.session_id)
assert len(summaries) == 1
assert summaries[0].summary_text == "short summary"
assert summaries[0].compaction_id == "cmp_inline_1"
assert summaries[0].trigger_reason == "agent_inline_overflow"
assert summaries[0].removed_count == 3
assert summaries[0].kept_count == 1
states = await manager.get_context_states("agent:main:main")
assert len(states) == 1
assert states[0].state_kind == "structured_summary_v1"
assert states[0].payload is not None
assert states[0].payload["compaction_id"] == "cmp_inline_1"
@pytest.mark.asyncio
async def test_delete_session_removes_compacted_transcript_archive(manager):
node = await manager.create("agent:main:main")
for index in range(4):
await manager.append_message("agent:main:main", "user", f"msg {index}", token_count=5)
await manager.persist_compaction_result(
"agent:main:main",
"short summary",
[{"role": "assistant", "content": "latest reply"}],
)
assert len(await manager.get_canonical_transcript("agent:main:main")) == 4
await manager._storage.delete_session("agent:main:main")
async with manager._storage.conn.execute(
"SELECT COUNT(*) FROM compacted_transcript_entries WHERE session_id = ?",
(node.session_id,),
) as cur:
archived_count = (await cur.fetchone())[0]
assert archived_count == 0
@pytest.mark.asyncio
async def test_persist_compaction_result_without_summary_does_not_rewrite_transcript(manager):
node = await manager.create("agent:main:main")
for index in range(5):
await manager.append_message("agent:main:main", "user", f"msg {index}", token_count=5)
original_transcript = await manager.get_transcript("agent:main:main")
original_node = await manager._storage.get_session("agent:main:main")
await manager.persist_compaction_result(
"agent:main:main",
"",
[{"role": "assistant", "content": "latest reply"}],
)
assert await manager.get_transcript("agent:main:main") == original_transcript
assert await manager.get_summaries("agent:main:main") == []
current_node = await manager._storage.get_session("agent:main:main")
assert current_node is not None
assert original_node is not None
assert current_node.session_id == node.session_id
assert current_node.compaction_count == original_node.compaction_count
assert current_node.updated_at == original_node.updated_at
@pytest.mark.asyncio
async def test_prune_stale(manager):
node = await manager.create("agent:main:main")
# force old timestamp
node.updated_at = 1
await manager._storage.upsert_session(node)
pruned = await manager.prune_stale(max_age_ms=1000)
assert pruned == 1
@pytest.mark.asyncio
async def test_cap_entries(manager):
for i in range(10):
await manager.create(f"agent:main:direct:u{i}")
deleted = await manager.cap_entries(max_entries=5)
assert deleted == 5
remaining = await manager._storage.count_sessions()
assert remaining == 5
@pytest.mark.asyncio
async def test_cap_entries_cleans_related_transcript_and_summaries(manager):
session_ids: dict[str, str] = {}
for i in range(3):
key = f"agent:main:direct:u{i}"
node = await manager.create(key)
session_ids[key] = node.session_id
await manager._storage.append_transcript_entry(
TranscriptEntry(
session_id=node.session_id,
session_key=key,
role="user",
content="stale",
)
)
await manager._storage.save_summary(
SessionSummary(
session_id=node.session_id,
session_key=key,
summary_text="summary",
)
)
await manager.save_context_state(
SessionContextState(
session_id=node.session_id,
session_key=key,
provider="portable",
state_kind="structured_summary_v1",
payload={"summary": "state"},
portable=True,
cacheable=True,
)
)
deleted = await manager.cap_entries(max_entries=1)
assert deleted == 2
remaining = {session.session_key for session in await manager._storage.list_sessions(limit=10)}
removed = set(session_ids) - remaining
assert len(removed) == 2
for key in removed:
session_id = session_ids[key]
assert await manager._storage.count_transcript_entries(session_id) == 0
assert await manager._storage.get_all_summaries(session_id) == []
assert await manager.get_context_states(key, valid_only=False) == []
@pytest.mark.asyncio
async def test_archive(manager):
await manager.create("agent:main:main")
await manager.archive("agent:main:main")
node = await manager._storage.get_session("agent:main:main")
assert node.status == SessionStatus.DONE