"""Tests for the task-body consolidation pipeline (gate + full pass). Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md """ from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch # ── Gate logic ─────────────────────────────────────────────────────────────── async def test_maybe_consolidate_below_threshold_does_not_fire(): """log_added with only 2 logs since last pass → no consolidation.""" from fabledassistant.services import consolidation with patch.object( consolidation, "_logs_since_last_consolidation", new=AsyncMock(return_value=2), ), patch.object( consolidation, "consolidate_task", new=AsyncMock(), ) as mock_consolidate, patch.object( consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True), ): await consolidation.maybe_consolidate( user_id=1, task_id=42, reason="log_added", ) mock_consolidate.assert_not_awaited() async def test_maybe_consolidate_at_threshold_fires(): """log_added with N logs since last pass → consolidation scheduled.""" from fabledassistant.services import consolidation # asyncio.create_task is the actual scheduler; replace it so the test # doesn't need an event-loop background task to settle. with patch.object( consolidation, "_logs_since_last_consolidation", new=AsyncMock(return_value=3), ), patch.object( consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True), ), patch( "fabledassistant.services.consolidation.asyncio.create_task", ) as mock_create_task: await consolidation.maybe_consolidate( user_id=1, task_id=42, reason="log_added", ) mock_create_task.assert_called_once() async def test_maybe_consolidate_task_closed_always_fires(): """task_closed bypasses the log-count gate.""" from fabledassistant.services import consolidation with patch.object( consolidation, "_logs_since_last_consolidation", new=AsyncMock(return_value=0), ), patch.object( consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True), ), patch( "fabledassistant.services.consolidation.asyncio.create_task", ) as mock_create_task: await consolidation.maybe_consolidate( user_id=1, task_id=42, reason="task_closed", ) mock_create_task.assert_called_once() async def test_maybe_consolidate_setting_off_blocks_both_reasons(): """auto_consolidate_tasks=false → neither trigger fires.""" from fabledassistant.services import consolidation with patch.object( consolidation, "_logs_since_last_consolidation", new=AsyncMock(return_value=5), ), patch.object( consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=False), ), patch( "fabledassistant.services.consolidation.asyncio.create_task", ) as mock_create_task: await consolidation.maybe_consolidate( user_id=1, task_id=42, reason="log_added", ) await consolidation.maybe_consolidate( user_id=1, task_id=42, reason="task_closed", ) mock_create_task.assert_not_called() async def test_maybe_consolidate_unknown_reason_is_noop(): """Unknown reasons get logged and skipped — not raised.""" from fabledassistant.services import consolidation with patch.object( consolidation, "_logs_since_last_consolidation", new=AsyncMock(return_value=99), ), patch.object( consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True), ), patch( "fabledassistant.services.consolidation.asyncio.create_task", ) as mock_create_task: await consolidation.maybe_consolidate( user_id=1, task_id=42, reason="some_other_reason", ) mock_create_task.assert_not_called() # ── Prompt builder ─────────────────────────────────────────────────────────── def test_build_consolidation_prompt_includes_title_goal_and_logs(): from fabledassistant.services.consolidation import _build_consolidation_prompt logs = [ SimpleNamespace( content="tried REJECT, logs noisy", created_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc), ), SimpleNamespace( content="switched to DROP", created_at=datetime(2026, 5, 13, 11, 0, tzinfo=timezone.utc), ), ] prompt = _build_consolidation_prompt( title="firewall tuning", description="quiet the logs", logs=logs, ) assert "firewall tuning" in prompt assert "quiet the logs" in prompt assert "tried REJECT" in prompt assert "switched to DROP" in prompt def test_build_consolidation_prompt_handles_missing_description(): from fabledassistant.services.consolidation import _build_consolidation_prompt logs = [ SimpleNamespace( content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc), ), ] prompt = _build_consolidation_prompt(title="t", description=None, logs=logs) assert "no goal recorded" in prompt def test_build_consolidation_prompt_truncates_to_char_budget(): from fabledassistant.services.consolidation import ( _build_consolidation_prompt, MAX_PROMPT_INPUT_CHARS, ) big = "x" * (MAX_PROMPT_INPUT_CHARS + 1000) logs = [ SimpleNamespace( content=big, created_at=datetime(2026, 5, 13, tzinfo=timezone.utc), ), SimpleNamespace( content="should be dropped", created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc), ), ] prompt = _build_consolidation_prompt(title="t", description=None, logs=logs) # First log consumes the budget; the second is dropped. assert "should be dropped" not in prompt # ── consolidate_task orchestration (mocked DB + LLM + embedding) ───────────── def _make_mock_session(task, logs): """Return a mock async_session that hands out task and logs to successive .execute() calls. consolidate_task calls execute three times: select task, select logs (same context), then re-select task in the write-back block.""" mock_session = AsyncMock() task_result = MagicMock() task_result.scalars.return_value.first.return_value = task logs_result = MagicMock() logs_result.scalars.return_value.all.return_value = logs mock_session.execute = AsyncMock( side_effect=[task_result, logs_result, task_result], ) mock_session.commit = AsyncMock() mock_session.refresh = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) return mock_session async def test_consolidate_task_writes_body_and_timestamp_and_reembeds(): task = MagicMock() task.id = 42 task.title = "Cert renewal" task.description = "renew LE before Nov 30" task.status = "in_progress" task.body = "old body" task.consolidated_at = None logs = [ SimpleNamespace( content="tried certbot renew", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc), ), SimpleNamespace( content="switched to manual", created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc), ), ] fake_summary = ( "Started with certbot renew; DNS split-horizon failed. " "Switched to manual flow." ) # Each consolidate_task call needs its own per-task lock state. # _locks is module-level defaultdict — patch it on the module to avoid # cross-test leakage of the held-lock state. from collections import defaultdict import asyncio as _asyncio with patch( "fabledassistant.services.consolidation._locks", new=defaultdict(_asyncio.Lock), ), patch( "fabledassistant.services.consolidation.async_session", return_value=_make_mock_session(task, logs), ), patch( "fabledassistant.services.llm.generate_completion", new=AsyncMock(return_value=fake_summary), ) as mock_llm, patch( "fabledassistant.services.embeddings.upsert_note_embedding", new=AsyncMock(), ) as mock_embed, patch( "fabledassistant.services.settings.get_setting", new=AsyncMock(return_value="gemma3:4b"), ): from fabledassistant.services.consolidation import consolidate_task await consolidate_task(1, 42) mock_llm.assert_awaited_once() mock_embed.assert_awaited_once() assert task.body == fake_summary assert task.consolidated_at is not None async def test_consolidate_task_llm_failure_leaves_body_untouched(): task = MagicMock() task.id = 42 task.title = "X" task.description = "y" task.status = "in_progress" task.body = "old body content" task.consolidated_at = None logs = [ SimpleNamespace( content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc), ), ] from collections import defaultdict import asyncio as _asyncio with patch( "fabledassistant.services.consolidation._locks", new=defaultdict(_asyncio.Lock), ), patch( "fabledassistant.services.consolidation.async_session", return_value=_make_mock_session(task, logs), ), patch( "fabledassistant.services.llm.generate_completion", new=AsyncMock(side_effect=RuntimeError("ollama down")), ), patch( "fabledassistant.services.embeddings.upsert_note_embedding", new=AsyncMock(), ) as mock_embed, patch( "fabledassistant.services.settings.get_setting", new=AsyncMock(return_value="gemma3:4b"), ): from fabledassistant.services.consolidation import consolidate_task await consolidate_task(1, 42) # must not raise assert task.body == "old body content" assert task.consolidated_at is None mock_embed.assert_not_awaited() async def test_consolidate_task_skips_when_no_logs(): task = MagicMock() task.id = 42 task.title = "X" task.description = "y" task.status = "in_progress" task.body = "old" task.consolidated_at = None from collections import defaultdict import asyncio as _asyncio with patch( "fabledassistant.services.consolidation._locks", new=defaultdict(_asyncio.Lock), ), patch( "fabledassistant.services.consolidation.async_session", return_value=_make_mock_session(task, []), ), patch( "fabledassistant.services.llm.generate_completion", new=AsyncMock(), ) as mock_llm, patch( "fabledassistant.services.settings.get_setting", new=AsyncMock(return_value="gemma3:4b"), ): from fabledassistant.services.consolidation import consolidate_task await consolidate_task(1, 42) mock_llm.assert_not_awaited() assert task.body == "old"