"""Unit tests for ReflectionCoach (the Phase 2 AI-coach mirror). The Anthropic client is faked so the suite is deterministic and offline. """ import json from types import SimpleNamespace import pytest from app.services.reflector import ReflectionCoach, ReflectionError PROFILE = { "triad": "gut", "love_summary": "You love building things with your hands.", "strength_summary": "You see what needs doing and move.", "mission_summary": "Protect people without power.", "vocation_summary": "Lead and build under pressure.", "overlap_narrative": "You come alive protecting others.", "short_term_goals": "Run a pilot welding cohort.", "long_term_goals": "A statewide trades outfit.", } class FakeMessages: def __init__(self, responses): self._responses = list(responses) self.calls = [] async def create(self, **kwargs): self.calls.append(kwargs) text = self._responses.pop(0) return SimpleNamespace(content=[SimpleNamespace(text=text)]) class FakeClient: def __init__(self, responses): self.messages = FakeMessages(responses) def make_coach(responses) -> ReflectionCoach: coach = ReflectionCoach(api_key="test-key") coach.client = FakeClient(responses) return coach def reply(message, revisions=None, note=None) -> str: return json.dumps( {"message": message, "revisions": revisions, "revision_note": note} ) @pytest.mark.asyncio async def test_opening_reflection_no_history(): coach = make_coach([reply("Here is what I am hearing — does it fit?")]) result = await coach.reflect(PROFILE, []) assert result["message"].startswith("Here is what I am hearing") assert result["revisions"] is None # The API call starts with the user primer (Anthropic requires user-first). msgs = coach.client.messages.calls[0]["messages"] assert msgs[0]["role"] == "user" assert len(msgs) == 1 # The profile context is injected into the system prompt. assert "welding" in coach.client.messages.calls[0]["system"] @pytest.mark.asyncio async def test_history_maps_roles_and_appends_primer(): coach = make_coach([reply("Got it.")]) history = [ {"role": "coach", "content": "Does this fit?"}, {"role": "person", "content": "Mostly, but change my goal."}, ] await coach.reflect(PROFILE, history) msgs = coach.client.messages.calls[0]["messages"] assert [m["role"] for m in msgs] == ["user", "assistant", "user"] assert msgs[-1]["content"] == "Mostly, but change my goal." @pytest.mark.asyncio async def test_revisions_filtered_to_editable_fields(): """The coach must never change structural fields like triad/type.""" coach = make_coach([ reply( "Updating that.", revisions={"short_term_goals": "New near-term goal.", "triad": "head"}, note="goal updated", ) ]) result = await coach.reflect(PROFILE, []) assert result["revisions"] == {"short_term_goals": "New near-term goal."} assert "triad" not in result["revisions"] assert result["revision_note"] == "goal updated" @pytest.mark.asyncio async def test_empty_revisions_become_none(): coach = make_coach([reply("Just reflecting.", revisions={})]) result = await coach.reflect(PROFILE, []) assert result["revisions"] is None @pytest.mark.asyncio async def test_bad_json_triggers_one_retry(): coach = make_coach(["not json at all", reply("Recovered.")]) result = await coach.reflect(PROFILE, []) assert result["message"] == "Recovered." assert len(coach.client.messages.calls) == 2 # Retry carries the JSON-only reminder. assert "JSON" in coach.client.messages.calls[1]["messages"][-1]["content"] @pytest.mark.asyncio async def test_missing_message_after_retry_raises(): coach = make_coach([json.dumps({"revisions": None}), json.dumps({"revisions": None})]) with pytest.raises(ReflectionError): await coach.reflect(PROFILE, []) assert len(coach.client.messages.calls) == 2 def test_missing_api_key_raises(): with pytest.raises(ReflectionError): ReflectionCoach(api_key="")