7910d266db
Brian fires a quick message then locks his phone to go play the hand — which drops the SSE stream, and on wake the UI re-fires via the blocking fallback. The prior (session, message) + 20s window caught the near-simultaneous case but not a re-fire minutes later. Now the UI stamps each send with a unique turnId (crypto.randomUUID) and carries the SAME id on both the stream and the fallback; the server dedupes on it. Bulletproof regardless of how long he's away — lock for an hour, come back, still exactly one execution and one log — and a genuinely new send gets a fresh id so nothing legit is swallowed. Id-keyed turns keep a long (1h) window; requests without an id keep the short (session, msg) window for near-simultaneous dupes. Verified end-to-end: two POSTs with the same turnId → one reply, one persisted exchange pair (the duplicate reused the owner's result). 9 dedup tests; suite 235. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
94 lines
3.4 KiB
Python
94 lines
3.4 KiB
Python
"""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"
|