from __future__ import annotations import asyncio import logging import time from types import SimpleNamespace from typing import Any, cast import pytest from opensquilla.engine import Agent, AgentConfig from opensquilla.memory.archive import write_raw_fallback_archive from opensquilla.memory.embedding import NullEmbeddingProvider from opensquilla.memory.flush import resolve_flush_plan from opensquilla.memory.protocols import MemoryToolHandler from opensquilla.memory.retrieval import MemoryRetriever from opensquilla.memory.session_flush import ( FlushReceipt, SessionFlushService, _make_flush_read_only_handler, ) from opensquilla.memory.store import LongTermMemoryStore from opensquilla.memory.sync_manager import MemorySyncManager from opensquilla.memory.types import MemorySearchOpts, SearchIntent from opensquilla.provider import DoneEvent, Message, ToolUseEndEvent, ToolUseStartEvent from opensquilla.tool_boundary import ToolCall, ToolResult def test_memory_tool_handler_protocol_uses_tool_boundary_types() -> None: async def handler(call: ToolCall) -> ToolResult: return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="ok", ) typed_handler: MemoryToolHandler = handler assert typed_handler is handler def test_resolve_flush_plan_rotates_oversized_daily_archive(tmp_path) -> None: first = resolve_flush_plan(workspace_dir=tmp_path, archive_max_bytes=5) first_path = tmp_path / first.relative_path first_path.parent.mkdir(parents=True) first_path.write_text("123456", encoding="utf-8") second = resolve_flush_plan(workspace_dir=tmp_path, archive_max_bytes=5) assert second.relative_path.endswith("-part001.md") second_path = tmp_path / second.relative_path second_path.write_text("123456", encoding="utf-8") third = resolve_flush_plan(workspace_dir=tmp_path, archive_max_bytes=5) assert third.relative_path.endswith("-part002.md") @pytest.mark.asyncio async def test_flush_runner_rejects_wrong_memory_path() -> None: calls: list[ToolCall] = [] async def handler(call: ToolCall) -> ToolResult: calls.append(call) return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="Saved to memory/other.md (1 chunks indexed; integrity=ok).", ) guarded = _make_flush_read_only_handler( handler, relative_path="memory/2026-05-28.md", ) result = await guarded( ToolCall( tool_use_id="flush-save-1", tool_name="memory_save", arguments={ "path": "memory/other.md", "content": "durable fact", "mode": "append", }, ) ) assert result.is_error assert "only append to" in result.content assert calls == [] @pytest.mark.asyncio async def test_flush_runner_allows_selected_part_archive_path(tmp_path) -> None: plan = resolve_flush_plan(workspace_dir=tmp_path, archive_max_bytes=5) first_path = tmp_path / plan.relative_path first_path.parent.mkdir(parents=True) first_path.write_text("123456", encoding="utf-8") plan = resolve_flush_plan(workspace_dir=tmp_path, archive_max_bytes=5) assert plan.relative_path.endswith("-part001.md") calls: list[ToolCall] = [] async def handler(call: ToolCall) -> ToolResult: calls.append(call) return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content=f"Saved to {plan.relative_path} (1 chunks indexed; integrity=ok).", ) class PartPathProvider: def __init__(self) -> None: self.calls = 0 async def chat(self, *_args: Any, **_kwargs: Any): self.calls += 1 if self.calls == 1: yield ToolUseStartEvent( tool_use_id="flush-save-1", tool_name="memory_save", ) yield ToolUseEndEvent( tool_use_id="flush-save-1", tool_name="memory_save", arguments={ "path": plan.relative_path, "content": "durable fact", "mode": "append", }, ) yield DoneEvent() service = SessionFlushService( provider_selector=lambda _agent_id: PartPathProvider(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, ) save_results, _done_event = await service._run_llm_flush_sub_agent( PartPathProvider(), agent_id="main", plan=plan, user_prompt="save durable memory", flush_tools=[SimpleNamespace(name="memory_save")], source_name="test", ) assert [result.path for result in save_results] == [plan.relative_path] assert calls and calls[0].arguments["path"] == plan.relative_path @pytest.mark.asyncio async def test_curated_flush_memory_is_searchable_but_raw_fallback_is_not(tmp_path) -> None: workspace = tmp_path / "agent" memory_dir = workspace / "memory" raw_dir = memory_dir / ".raw_fallbacks" raw_dir.mkdir(parents=True) (memory_dir / "2026-05-14-session.md").write_text( "Flush summary: zebra77 project decision\n", encoding="utf-8", ) (raw_dir / "raw.md").write_text( "Raw fallback transcript: zebra77 should not be searched\n", encoding="utf-8", ) store = LongTermMemoryStore( str(tmp_path / "memory.db"), embedding_provider=NullEmbeddingProvider(), ) await store.initialize() try: sync = MemorySyncManager(store=store, workspace_dir=workspace, memory_dir=memory_dir) await sync.sync(reason="manual") retriever = MemoryRetriever(store) results = await retriever.search( "zebra77", MemorySearchOpts(max_results=5, min_score=0.0), intent=SearchIntent.TOOL, ) paths = [result.path for result in results] assert "memory/2026-05-14-session.md" in paths assert all(".raw_fallbacks" not in path for path in paths) finally: await store.close() @pytest.mark.asyncio async def test_agent_memory_flush_timeout_enters_backoff_without_retrigger( monkeypatch: pytest.MonkeyPatch, ) -> None: import opensquilla.engine.agent as agent_module async def fake_compact_context(_request): return SimpleNamespace( removed_count=0, summary="", kept_entries=[{"role": "user", "content": "hello"}], ) monkeypatch.setattr(agent_module, "compact_context", fake_compact_context) agent = Agent( provider=None, # type: ignore[arg-type] config=AgentConfig( context_window_tokens=100, context_overflow_threshold=0.5, flush_enabled=True, flush_pre_compaction=True, flush_timeout_seconds=0.01, flush_backoff_initial_seconds=10.0, flush_backoff_max_seconds=20.0, ), ) calls = 0 async def slow_flush(_plan, _messages): nonlocal calls calls += 1 await asyncio.sleep(1) monkeypatch.setattr(agent, "_run_flush", slow_flush) messages = [Message(role="user", content="hello")] try: await agent._check_context_overflow(messages, 60) first_backoff_until = agent._flush_backoff_until await agent._check_context_overflow(messages, 60) finally: task = agent._active_flush_task if task is not None and not task.done(): task.cancel() with pytest.raises(asyncio.CancelledError): await task assert calls == 1 assert first_backoff_until > time.monotonic() assert agent._flush_backoff_seconds == 10.0 @pytest.mark.asyncio async def test_agent_memory_flush_timeout_records_backoff_and_compacts( monkeypatch: pytest.MonkeyPatch, ) -> None: import opensquilla.engine.agent as agent_module async def fake_compact_context(_request): return SimpleNamespace( removed_count=1, summary="User asked for memory flush timeout recovery.", kept_entries=[{"role": "user", "content": "latest turn"}], ) monkeypatch.setattr(agent_module, "compact_context", fake_compact_context) agent = Agent( provider=None, # type: ignore[arg-type] config=AgentConfig( context_window_tokens=100, context_overflow_threshold=0.5, flush_enabled=True, flush_pre_compaction=True, flush_timeout_seconds=0.01, flush_backoff_initial_seconds=10.0, flush_backoff_max_seconds=20.0, ), ) calls = 0 async def slow_flush(_plan, _messages): nonlocal calls calls += 1 await asyncio.sleep(1) monkeypatch.setattr(agent, "_run_flush", slow_flush) messages = [ Message(role="user", content="older turn"), Message(role="assistant", content="older answer"), Message(role="user", content="latest turn"), ] try: outcome = await agent._check_context_overflow(messages, 60) finally: task = agent._active_flush_task if task is not None and not task.done(): task.cancel() with pytest.raises(asyncio.CancelledError): await task assert outcome is not None assert outcome.compacted is True assert outcome.summary == "User asked for memory flush timeout recovery." assert calls == 1 assert agent._flush_backoff_seconds == 10.0 assert agent._last_compaction_refusal_reason is None @pytest.mark.asyncio async def test_agent_memory_flush_service_uses_background_timeout() -> None: class RecordingFlushService: def __init__(self) -> None: self.kwargs: dict[str, Any] | None = None async def execute(self, *_args, **kwargs): self.kwargs = kwargs return FlushReceipt( mode="llm", flushed_paths=["memory/ok.md"], slug="ok", message_count=1, duration_ms=1, raw_reason=None, error=None, indexed_chunk_count=1, integrity_status="ok", output_coverage_status="ok", obligation_status="ok", ) service = RecordingFlushService() agent = Agent( provider=None, # type: ignore[arg-type] config=AgentConfig(flush_timeout_seconds=0.01, flush_background_timeout_seconds=42.0), session_flush_service=service, session_key="agent:main:webchat:s1", ) await agent._run_flush(SimpleNamespace(relative_path="memory/2026-05-14.md"), []) assert service.kwargs is not None assert service.kwargs["timeout"] == 42.0 @pytest.mark.asyncio async def test_agent_memory_flush_raw_receipt_keeps_backoff() -> None: agent = Agent( provider=None, # type: ignore[arg-type] config=AgentConfig( flush_backoff_initial_seconds=10.0, flush_backoff_max_seconds=20.0, ), session_key="agent:main:webchat:s1", ) class RawFlushService: async def execute(self, *_args, **_kwargs) -> FlushReceipt: return FlushReceipt( mode="raw", flushed_paths=["memory/.raw_fallbacks/raw.md"], slug=None, message_count=1, duration_ms=1, raw_reason="timeout", error=None, ) cast(Any, agent)._session_flush_service = RawFlushService() task = asyncio.create_task( agent._run_flush(SimpleNamespace(relative_path="memory/2026-05-14.md"), []) ) agent._active_flush_task = task await task agent._mark_flush_task_completed(task) assert agent._flush_backoff_until > time.monotonic() assert agent._flush_backoff_seconds == 10.0 @pytest.mark.asyncio async def test_agent_memory_flush_noop_llm_receipt_clears_backoff() -> None: agent = Agent( provider=None, # type: ignore[arg-type] config=AgentConfig( flush_backoff_initial_seconds=10.0, flush_backoff_max_seconds=20.0, ), session_key="agent:main:webchat:s1", ) async def noop_flush() -> FlushReceipt: return FlushReceipt( mode="llm", flushed_paths=[], slug="no-memory", message_count=1, duration_ms=1, raw_reason=None, error=None, result_status="ok_noop_no_memory", ) agent._flush_backoff_until = time.monotonic() + 10.0 agent._flush_backoff_seconds = 10.0 task = asyncio.create_task(noop_flush()) agent._active_flush_task = task await task agent._mark_flush_task_completed(task) assert agent._flush_backoff_until == 0.0 assert agent._flush_backoff_seconds == 0.0 @pytest.mark.asyncio async def test_agent_memory_flush_unsafe_llm_receipt_keeps_backoff() -> None: agent = Agent( provider=None, # type: ignore[arg-type] config=AgentConfig( flush_backoff_initial_seconds=10.0, flush_backoff_max_seconds=20.0, ), session_key="agent:main:webchat:s1", ) async def unsafe_flush() -> SimpleNamespace: return SimpleNamespace( mode="llm", indexed_chunk_count=1, integrity_status="degraded", output_coverage_status="ok", invalid_candidate_count=0, candidate_missing_ids=[], obligation_status="ok", obligation_missing_ids=[], ) task = asyncio.create_task(unsafe_flush()) agent._active_flush_task = task await task agent._mark_flush_task_completed(task) assert agent._flush_backoff_until > time.monotonic() assert agent._flush_backoff_seconds == 10.0 @pytest.mark.asyncio async def test_session_flush_raw_fallback_deduplicates_same_transcript(tmp_path) -> None: calls: list[ToolCall] = [] async def handler(call: ToolCall) -> ToolResult: calls.append(call) raise AssertionError("raw fallback must not call memory_save") service = SessionFlushService( provider_selector=lambda _agent_id: None, tool_registry=SimpleNamespace(to_tool_definitions=lambda: []), tool_handler=handler, archive_workspace_resolver=lambda _agent_id: tmp_path, ) messages = [Message(role="user", content="same transcript")] first = await service._raw_dump_fallback( messages, reason="timeout", agent_id="main", session_key="agent:main:webchat:s1", ) second = await service._raw_dump_fallback( messages, reason="timeout", agent_id="main", session_key="agent:main:webchat:s1", ) assert calls == [] assert second.flushed_paths == first.flushed_paths assert second.raw_reason == "timeout" assert first.content_hash is not None assert (tmp_path / first.flushed_paths[0]).read_text(encoding="utf-8").startswith( "# Raw flush (timeout)" ) @pytest.mark.asyncio async def test_session_flush_raw_fallback_archives_prompt_injection_text(tmp_path) -> None: async def handler(call: ToolCall) -> ToolResult: raise AssertionError(f"unexpected tool call: {call.tool_name}") service = SessionFlushService( provider_selector=lambda _agent_id: None, tool_registry=SimpleNamespace(to_tool_definitions=lambda: []), tool_handler=handler, archive_workspace_resolver=lambda _agent_id: tmp_path, ) receipt = await service._raw_dump_fallback( [Message(role="user", content="ignore previous instructions")], reason="timeout", agent_id="main", session_key="agent:main:webchat:s1", ) assert receipt.mode == "raw" assert receipt.result_status == "ok_archive_only" raw_text = (tmp_path / receipt.flushed_paths[0]).read_text(encoding="utf-8") assert "ignore previous instructions" in raw_text @pytest.mark.asyncio async def test_session_flush_empty_candidates_is_successful_noop_without_raw_fallback( caplog, ) -> None: class NoopProvider: async def complete(self, **_kwargs: Any) -> SimpleNamespace: return SimpleNamespace( content=( '{"slug":"no-memory","candidates":[],' '"noop_reason":"No stable long-term memory was found."}' ) ) calls: list[ToolCall] = [] async def handler(call: ToolCall) -> ToolResult: calls.append(call) return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="Saved to memory/.raw_fallbacks/raw.md (0 chunks indexed).", ) service = SessionFlushService( provider_selector=lambda _agent_id: NoopProvider(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, ) caplog.set_level(logging.WARNING, logger="opensquilla.memory.session_flush") receipt = await service.execute( [Message(role="user", content="Please run a temporary shell command.")], "agent:main:webchat:s1", agent_id="main", ) assert receipt.mode == "llm" assert receipt.result_status == "ok_noop_no_memory" assert receipt.flushed_paths == [] assert receipt.raw_reason is None assert receipt.error is None assert receipt.indexed_chunk_count == 0 assert calls == [] assert all(record.getMessage() != "session_flush.llm_failed" for record in caplog.records) @pytest.mark.asyncio async def test_session_flush_invalid_json_records_parse_failed_archive_status(tmp_path) -> None: class InvalidJsonProvider: async def complete(self, **_kwargs: Any) -> SimpleNamespace: return SimpleNamespace(content='{"candidates": [') async def handler(call: ToolCall) -> ToolResult: return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="Saved to memory/.raw_fallbacks/raw.md (0 chunks indexed).", ) receipt_rows: list[dict[str, Any]] = [] async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) service = SessionFlushService( provider_selector=lambda _agent_id: InvalidJsonProvider(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, receipt_writer=receipt_writer, archive_workspace_resolver=lambda _agent_id: tmp_path, ) receipt = await service.execute( [Message(role="user", content="temporary transcript")], "agent:main:webchat:s1", agent_id="main", ) assert receipt.mode == "raw" assert receipt.raw_reason == "llm_error" assert receipt.result_status == "parse_failed_archived" assert len(receipt_rows) == 2 assert {"content_hash", "source_path", "turn_id", "session_id"} <= set( receipt_rows[1] ) assert receipt_rows[0]["scope"] == "preimage" assert receipt_rows[0]["status"] == "preimage_saved" assert receipt_rows[1]["scope"] == "repair" assert receipt_rows[1]["status"] == "repair_pending" assert receipt_rows[1]["reason"] == "parse_failed_archived" assert receipt_rows[1]["target_path"] == receipt.flushed_paths[0] assert receipt_rows[1]["turn_id"] == "flush:1-1" assert receipt_rows[1]["content_hash"] == receipt.content_hash @pytest.mark.asyncio async def test_session_flush_provider_exception_records_provider_failed_archive_status( tmp_path, ) -> None: class FailingProvider: async def complete(self, **_kwargs: Any) -> SimpleNamespace: raise RuntimeError("provider unavailable") async def handler(call: ToolCall) -> ToolResult: return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="Saved to memory/.raw_fallbacks/raw.md (0 chunks indexed).", ) receipt_rows: list[dict[str, Any]] = [] async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) service = SessionFlushService( provider_selector=lambda _agent_id: FailingProvider(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, receipt_writer=receipt_writer, archive_workspace_resolver=lambda _agent_id: tmp_path, ) receipt = await service.execute( [Message(role="user", content="temporary transcript")], "agent:main:webchat:s1", agent_id="main", ) assert receipt.mode == "raw" assert receipt.raw_reason == "llm_error" assert receipt.result_status == "provider_failed_archived" assert len(receipt_rows) == 2 assert receipt_rows[0]["scope"] == "preimage" assert receipt_rows[0]["status"] == "preimage_saved" assert receipt_rows[1]["scope"] == "repair" assert receipt_rows[1]["status"] == "repair_pending" assert receipt_rows[1]["reason"] == "provider_failed_archived" assert receipt_rows[1]["target_path"] == receipt.flushed_paths[0] assert receipt_rows[1]["content_hash"] == receipt.content_hash @pytest.mark.asyncio async def test_session_flush_required_raw_capture_happens_before_provider( tmp_path, ) -> None: events: list[str] = [] class ValidProvider: async def complete(self, **_kwargs: Any) -> SimpleNamespace: events.append("provider") return SimpleNamespace( content=( '{"slug":"durable-fact","candidates":[{' '"kind":"decision",' '"render_text":"User decided to drink tea daily yesterday.",' '"source_message":1,"source_date":"2026-05-29",' '"confidence":0.9,"date":"2026-05-28",' '"date_basis":"relative:yesterday","granularity":"day"}]}' ) ) def archive_writer(*args: Any, **kwargs: Any): events.append(f"archive:{kwargs.get('reason')}") return write_raw_fallback_archive(*args, **kwargs) async def handler(call: ToolCall) -> ToolResult: events.append("memory_save") return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="Saved to memory/durable-fact.md (1 chunks indexed; integrity=ok).", ) receipt_rows: list[dict[str, Any]] = [] async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) service = SessionFlushService( provider_selector=lambda _agent_id: ValidProvider(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, receipt_writer=receipt_writer, archive_workspace_resolver=lambda _agent_id: tmp_path, archive_writer=archive_writer, ) receipt = await service.execute( [Message(role="user", content="Yesterday I decided to drink tea daily.")], "agent:main:webchat:s1", agent_id="main", raw_capture_policy="required", ) assert receipt.mode == "llm" assert receipt.result_status == "ok_candidates_written" assert events == ["archive:preimage", "provider", "memory_save"] assert [row["status"] for row in receipt_rows] == [ "preimage_saved", "flush_appended", ] assert receipt_rows[0]["scope"] == "preimage" assert receipt_rows[0]["target_path"].startswith("memory/.raw_fallbacks/") assert receipt_rows[1]["scope"] == "flush" @pytest.mark.asyncio async def test_session_flush_required_raw_capture_failure_blocks_provider( tmp_path, ) -> None: provider_calls = 0 class Provider: async def complete(self, **_kwargs: Any) -> SimpleNamespace: nonlocal provider_calls provider_calls += 1 return SimpleNamespace(content='{"noop_reason":"nothing durable"}') def failing_archive_writer(*_args: Any, **_kwargs: Any): raise OSError("disk full") async def handler(call: ToolCall) -> ToolResult: raise AssertionError(f"provider must not reach {call.tool_name}") service = SessionFlushService( provider_selector=lambda _agent_id: Provider(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, archive_workspace_resolver=lambda _agent_id: tmp_path, archive_writer=failing_archive_writer, ) receipt = await service.execute( [Message(role="user", content="Remember this before compacting.")], "agent:main:webchat:s1", agent_id="main", raw_capture_policy="required", ) assert provider_calls == 0 assert receipt.mode == "error" assert receipt.result_status == "archive_failed" assert "raw fallback archive write failed" in str(receipt.error) @pytest.mark.asyncio async def test_session_flush_repairs_invalid_json_before_raw_degrade( tmp_path, ) -> None: provider_prompts: list[str] = [] class RepairingProvider: async def complete(self, **kwargs: Any) -> SimpleNamespace: provider_prompts.append(kwargs["messages"][0].content) if len(provider_prompts) == 1: return SimpleNamespace(content='{"candidates": [') return SimpleNamespace( content='{"slug":"durable-fact","markdown":"## Facts\\n- User prefers green tea."}' ) async def handler(call: ToolCall) -> ToolResult: return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="Saved to memory/durable-fact.md (1 chunks indexed; integrity=ok).", ) service = SessionFlushService( provider_selector=lambda _agent_id: RepairingProvider(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, archive_workspace_resolver=lambda _agent_id: tmp_path, ) receipt = await service.execute( [Message(role="user", content="Remember that I prefer green tea.")], "agent:main:webchat:s1", agent_id="main", raw_capture_policy="required", ) assert len(provider_prompts) == 2 assert "repair" in provider_prompts[1].lower() assert receipt.mode == "llm" assert receipt.result_status == "ok_candidates_written" assert receipt.flushed_paths == ["memory/durable-fact.md"] @pytest.mark.asyncio async def test_session_flush_memory_save_error_reuses_preimage_archive( tmp_path, ) -> None: class ValidProvider: async def complete(self, **_kwargs: Any) -> SimpleNamespace: return SimpleNamespace( content='{"slug":"durable-fact","markdown":"## Facts\\n- Durable fact."}' ) archive_calls = 0 def archive_writer(*args: Any, **kwargs: Any): nonlocal archive_calls archive_calls += 1 return write_raw_fallback_archive(*args, **kwargs) async def handler(call: ToolCall) -> ToolResult: return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="scanner blocked memory_save", is_error=True, ) receipt_rows: list[dict[str, Any]] = [] async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) service = SessionFlushService( provider_selector=lambda _agent_id: ValidProvider(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, receipt_writer=receipt_writer, archive_workspace_resolver=lambda _agent_id: tmp_path, archive_writer=archive_writer, ) receipt = await service.execute( [Message(role="user", content="Remember this durable fact.")], "agent:main:webchat:s1", agent_id="main", raw_capture_policy="required", ) assert archive_calls == 1 assert receipt.mode == "raw" assert receipt.result_status == "apply_failed_archived" assert receipt.raw_reason == "llm_error" assert receipt.flushed_paths[0].startswith("memory/.raw_fallbacks/") assert [row["status"] for row in receipt_rows] == [ "preimage_saved", "repair_pending", ] assert receipt_rows[1]["reason"] == "apply_failed_archived" assert receipt_rows[1]["target_path"] == receipt.flushed_paths[0] @pytest.mark.asyncio async def test_session_flush_successful_llm_flush_records_flush_appended() -> None: async def handler(call: ToolCall) -> ToolResult: return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="Saved to memory/session.md (1 chunks indexed; integrity=ok).", ) receipt_rows: list[dict[str, Any]] = [] async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) service = SessionFlushService( provider_selector=lambda _agent_id: object(), tool_registry=SimpleNamespace( to_tool_definitions=lambda: [SimpleNamespace(name="memory_save")] ), tool_handler=handler, receipt_writer=receipt_writer, ) async def safe_llm_flush(*_args: Any, **_kwargs: Any) -> FlushReceipt: return FlushReceipt( mode="llm", flushed_paths=["memory/session.md"], slug="session", message_count=1, duration_ms=1, raw_reason=None, error=None, result_status="ok_candidates_written", integrity_status="ok", indexed_chunk_count=1, output_coverage_status="ok", invalid_candidate_count=0, candidate_missing_ids=[], obligation_status="ok", obligation_missing_ids=[], ) service._llm_flush = safe_llm_flush # type: ignore[method-assign] receipt = await service.execute( [Message(role="user", content="remember this durable fact")], "agent:main:webchat:s1", agent_id="main", ) assert receipt.result_status == "ok_candidates_written" assert len(receipt_rows) == 1 assert receipt_rows[0]["scope"] == "flush" assert receipt_rows[0]["status"] == "flush_appended" assert receipt_rows[0]["reason"] is None assert receipt_rows[0]["target_path"] == "memory/session.md" assert receipt_rows[0]["turn_id"] == "flush:1-1" assert receipt_rows[0]["content_hash"] == receipt.content_hash @pytest.mark.asyncio async def test_session_flush_raw_fallback_archive_error_returns_error_receipt( caplog, tmp_path, ) -> None: def failing_archive_writer(*args: Any, **kwargs: Any): raise OSError("disk full") receipt_rows: list[dict[str, Any]] = [] async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) async def handler(call: ToolCall) -> ToolResult: raise AssertionError(f"raw fallback must not call {call.tool_name}") service = SessionFlushService( provider_selector=lambda _agent_id: None, tool_registry=SimpleNamespace(to_tool_definitions=lambda: []), tool_handler=handler, receipt_writer=receipt_writer, archive_workspace_resolver=lambda _agent_id: tmp_path, archive_writer=failing_archive_writer, ) caplog.set_level(logging.INFO, logger="opensquilla.memory.session_flush") first = await service._raw_dump_fallback( [Message(role="user", content="same transcript")], reason="timeout", agent_id="main", session_key="agent:main:webchat:s1", ) second = await service._raw_dump_fallback( [Message(role="user", content="same transcript")], reason="timeout", agent_id="main", session_key="agent:main:webchat:s1", ) assert first.mode == "error" assert first.flushed_paths == [] assert first.raw_reason is None assert first.error == "raw fallback archive write failed: disk full" assert first.result_status == "archive_failed" assert second.mode == "error" assert [row["scope"] for row in receipt_rows] == ["repair", "repair"] assert [row["status"] for row in receipt_rows] == ["repair_failed", "repair_failed"] assert "session_flush.raw_fallback_save_failed" not in [ record.getMessage() for record in caplog.records ] assert "session_flush.raw_fallback_archive_failed" in [ record.getMessage() for record in caplog.records ] @pytest.mark.asyncio async def test_session_flush_raw_fallback_sync_resolver_error_returns_error_receipt( tmp_path, ) -> None: receipt_rows: list[dict[str, Any]] = [] async def handler(call: ToolCall) -> ToolResult: raise AssertionError(f"raw fallback must not call {call.tool_name}") async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) def resolver(_agent_id: str): raise RuntimeError("resolver failed") service = SessionFlushService( provider_selector=lambda _agent_id: None, tool_registry=SimpleNamespace(to_tool_definitions=lambda: []), tool_handler=handler, receipt_writer=receipt_writer, archive_workspace_resolver=resolver, ) receipt = await service._raw_dump_fallback( [Message(role="user", content="same transcript")], reason="timeout", agent_id="main", session_key="agent:main:webchat:s1", ) assert receipt.mode == "error" assert receipt.flushed_paths == [] assert receipt.raw_reason is None assert receipt.error == "raw fallback archive write failed: resolver failed" assert receipt.result_status == "archive_failed" assert receipt.content_hash is not None assert [row["scope"] for row in receipt_rows] == ["repair"] assert [row["status"] for row in receipt_rows] == ["repair_failed"] @pytest.mark.asyncio async def test_session_flush_raw_fallback_async_resolver_error_returns_error_receipt( tmp_path, ) -> None: receipt_rows: list[dict[str, Any]] = [] async def handler(call: ToolCall) -> ToolResult: raise AssertionError(f"raw fallback must not call {call.tool_name}") async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) async def resolver(_agent_id: str): raise RuntimeError("resolver failed") service = SessionFlushService( provider_selector=lambda _agent_id: None, tool_registry=SimpleNamespace(to_tool_definitions=lambda: []), tool_handler=handler, receipt_writer=receipt_writer, archive_workspace_resolver=resolver, ) receipt = await service._raw_dump_fallback( [Message(role="user", content="same transcript")], reason="timeout", agent_id="main", session_key="agent:main:webchat:s1", ) assert receipt.mode == "error" assert receipt.flushed_paths == [] assert receipt.raw_reason is None assert receipt.error == "raw fallback archive write failed: resolver failed" assert receipt.result_status == "archive_failed" assert receipt.content_hash is not None assert [row["scope"] for row in receipt_rows] == ["repair"] assert [row["status"] for row in receipt_rows] == ["repair_failed"] @pytest.mark.asyncio async def test_session_flush_archive_failed_without_checkpoint_records_checkpoint_failed() -> None: async def handler(call: ToolCall) -> ToolResult: return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="disk full", is_error=True, ) receipt_rows: list[dict[str, Any]] = [] async def receipt_writer(_receipt: FlushReceipt, **row: Any) -> None: receipt_rows.append(row) service = SessionFlushService( provider_selector=lambda _agent_id: None, tool_registry=SimpleNamespace(to_tool_definitions=lambda: []), tool_handler=handler, receipt_writer=receipt_writer, ) receipt = await service.execute( [Message(role="user", content="same transcript")], "agent:main:webchat:s1", agent_id="main", checkpoint_exists=False, ) assert receipt.result_status == "archive_failed" assert len(receipt_rows) == 1 assert receipt_rows[0]["scope"] == "checkpoint" assert receipt_rows[0]["status"] == "checkpoint_failed" assert receipt_rows[0]["reason"] == "archive_failed" assert receipt_rows[0]["target_path"] is None assert receipt_rows[0]["content_hash"] == receipt.content_hash @pytest.mark.asyncio async def test_session_flush_execute_logs_done_receipt_for_raw_fallback(caplog, tmp_path) -> None: async def handler(call: ToolCall) -> ToolResult: return ToolResult( tool_use_id=call.tool_use_id, tool_name=call.tool_name, content="Saved to memory/.raw_fallbacks/raw.md (0 chunks indexed).", ) service = SessionFlushService( provider_selector=lambda _agent_id: None, tool_registry=SimpleNamespace(to_tool_definitions=lambda: []), tool_handler=handler, archive_workspace_resolver=lambda _agent_id: tmp_path, ) caplog.set_level(logging.INFO, logger="opensquilla.memory.session_flush") receipt = await service.execute( [Message(role="user", content="flush this transcript")], "agent:main:webchat:s1", agent_id="main", ) assert receipt.mode == "raw" assert receipt.result_status == "ok_archive_only" raw_messages = [ record.getMessage() for record in caplog.records if record.name == "opensquilla.memory.session_flush" and "raw_fallback" in record.getMessage() ] assert "session_flush.raw_fallback_save_failed" not in raw_messages assert any(message == "session_flush.raw_fallback" for message in raw_messages) assert all( record.levelname != "ERROR" for record in caplog.records if record.name == "opensquilla.memory.session_flush" and "raw_fallback" in record.getMessage() ) records = [ record for record in caplog.records if record.name == "opensquilla.memory.session_flush" and record.getMessage() == "session_flush.done" ] assert len(records) == 1 assert records[0].session_key == "agent:main:webchat:s1" assert records[0].agent_id == "main" assert records[0].flush_mode == "raw" assert records[0].raw_reason == "no_provider" assert records[0].flushed_paths == receipt.flushed_paths assert records[0].flushed_paths[0].startswith("memory/.raw_fallbacks/")