"""Turn de-duplication: the UI hits two endpoints for one message (SSE stream + blocking fallback). Only the first should execute; the duplicate reuses its result.""" from __future__ import annotations import pytest from lyra import chat @pytest.fixture(autouse=True) def _clean_turns(): chat._turns.clear() yield chat._turns.clear() def test_claim_is_owner_once_per_key(): o1, r1 = chat._claim_turn("s1", "flopped a set") o2, r2 = chat._claim_turn("s1", "flopped a set") assert o1 is True and o2 is False assert r1 is r2 # the duplicate waits on the SAME record def test_different_messages_each_own(): o1, _ = chat._claim_turn("s1", "hand A") o2, _ = chat._claim_turn("s1", "hand B") o3, _ = chat._claim_turn("s2", "hand A") # different session assert o1 and o2 and o3 def test_await_returns_owner_reply(): _, rec = chat._claim_turn("s1", "msg") chat._finish_turn(rec, "the answer") assert chat._await_duplicate(rec) == "the answer" def test_respond_duplicate_reuses_result_without_running_turn(monkeypatch): # owner already ran and cached its reply _, rec = chat._claim_turn("s1", "same hand") chat._finish_turn(rec, "owner reply") def boom(*a, **k): raise AssertionError("duplicate must NOT execute the turn") monkeypatch.setattr(chat.mind, "assemble", boom) out = chat.respond("s1", "same hand", "cloud") assert out == "owner reply" def test_respond_stream_duplicate_yields_cached_reply(monkeypatch): _, rec = chat._claim_turn("s1", "same hand") chat._finish_turn(rec, "owner reply") def boom(*a, **k): raise AssertionError("duplicate must NOT execute the turn") monkeypatch.setattr(chat.mind, "assemble", boom) events = list(chat.respond_stream("s1", "same hand", "cloud")) assert ("delta", "owner reply") in events assert ("done", "owner reply") in events def test_fresh_message_after_window_runs_again(): # a completed turn lingers only briefly; simulate expiry and confirm re-ownership o1, rec = chat._claim_turn("s1", "later resend") chat._finish_turn(rec, "first") rec["ts"] -= chat._TURN_TTL_MSG + 1 # age it past the (session,msg) window o2, _ = chat._claim_turn("s1", "later resend") assert o1 and o2 # a genuine later resend runs fresh # --- client turn-id keying (the fire-and-forget guarantee) ---------------- def test_same_turn_id_dedupes_regardless_of_message(): # the fallback may resend the SAME id; dedupe on the id, not the text o1, r1 = chat._claim_turn("s1", "a hand", turn_id="tid-123") o2, r2 = chat._claim_turn("s1", "a hand", turn_id="tid-123") assert o1 is True and o2 is False and r1 is r2 def test_different_turn_ids_each_own(): o1, _ = chat._claim_turn("s1", "same text", turn_id="tid-1") o2, _ = chat._claim_turn("s1", "same text", turn_id="tid-2") assert o1 and o2 # a genuinely new send never gets swallowed def test_turn_id_window_survives_long_after_the_msg_window(): # locked-phone case: the re-fire can arrive minutes later and must still dedupe o1, rec = chat._claim_turn("s1", "big hand", turn_id="tid-lock") chat._finish_turn(rec, "cached") rec["ts"] -= chat._TURN_TTL_MSG + 60 # well past the short window, but not the id window o2, r2 = chat._claim_turn("s1", "big hand", turn_id="tid-lock") assert o1 and o2 is False and r2["reply"] == "cached"