"""Tests for the per-speaker pending-threads helpers in `app.services.orchestrator`. These guard the prompt rule: when it's your turn to speak, you should address questions/replies aimed at you since your last own-message turn before moving on. The helpers feed the `{pending_block}` template slot in CRITIQUE / FINALIZATION / CONSENSUS_ALLIED / CONSENSUS_SOLO and the `replying_to` field on outgoing messages (frontend "Replying to X" pill). """ from app.services.models import Participant, Session from app.services.orchestrator import ( _format_pending_block, _pending_addressed_for, _replying_to_ids, ) def _p(pid: str, name: str) -> Participant: return Participant( participant_id=pid, name=name, role_prompt="(prompt)", model_id="gpt-4o-mini", ) def _msg( speaker_id: str, speaker_name: str, text: str, *, role: str = "participant", addressed_to: str | None = None, ) -> dict: return { "speaker_id": speaker_id, "speaker_name": speaker_name, "role": role, "text": text, "addressed_to": addressed_to, } def test_pending_empty_when_no_addressed_messages(): s = Session() s.participants = [_p("a", "Alice"), _p("b", "Bob")] s.messages = [ _msg("a", "Alice", "Open thoughts."), _msg("b", "Bob", "Different topic."), ] pending = _pending_addressed_for(s, s.participants[0]) assert pending == [] def test_pending_collects_only_messages_after_speakers_last_turn(): s = Session() alice = _p("a", "Alice") bob = _p("b", "Bob") cara = _p("c", "Cara") s.participants = [alice, bob, cara] s.messages = [ _msg("a", "Alice", "Old message from Alice", addressed_to="a"), _msg("a", "Alice", "Alice speaks again."), _msg("b", "Bob", "Bob asks Alice something.", addressed_to="a"), _msg("c", "Cara", "Cara also asks Alice.", addressed_to="a"), _msg("c", "Cara", "Cara on a different point.", addressed_to="b"), ] pending = _pending_addressed_for(s, alice) assert pending == [ ("b", "Bob", "Bob asks Alice something."), ("c", "Cara", "Cara also asks Alice."), ] def test_pending_ignores_orchestrator_messages(): s = Session() alice = _p("a", "Alice") s.participants = [alice] s.messages = [ _msg("a", "Alice", "Hi.", addressed_to=None), _msg( "orch", "Orchestrator", "Some status.", role="orchestrator", addressed_to="a", ), ] pending = _pending_addressed_for(s, alice) assert pending == [] def test_pending_block_renders_none_when_empty(): block = _format_pending_block([]) assert "(none)" in block assert block.endswith("\n\n") def test_pending_block_renders_each_thread_with_speaker_attribution(): block = _format_pending_block([ ("b", "Bob", "Can you cite the source?"), ("c", "Cara", "I disagree because X."), ]) assert "Bob said to you" in block assert "Can you cite the source?" in block assert "Cara said to you" in block assert "I disagree because X." in block def test_pending_block_truncates_very_long_quotes(): long_text = "x" * 2000 block = _format_pending_block([("b", "Bob", long_text)]) assert "..." in block # Very rough upper bound: truncated quote + framing should be well under # the original 2000 chars. assert len(block) < 1000 def test_replying_to_ids_extracts_unique_ordered_asker_ids(): pending = [ ("b", "Bob", "First Bob question."), ("c", "Cara", "Cara chimes in."), ("b", "Bob", "Bob follows up - same id, should de-dupe."), ("d", "Dan", "Dan also."), ] assert _replying_to_ids(pending) == ["b", "c", "d"] def test_replying_to_ids_empty_for_empty_pending(): assert _replying_to_ids([]) == []