f3ecf8ffe4
The UI POSTs the SSE stream and, when nothing streams to the browser (iOS can't read a fetch-stream body → the fetch throws in ~1s), falls back to the blocking endpoint. But the server-side stream runs to completion regardless, so BOTH turns executed — double-persisting the message and (once logging became guaranteed) double-logging the hand. Make a turn idempotent instead of chasing why the client bails: the first request for a (session, message) owns it; a concurrent duplicate waits on the owner's Event and reuses its reply rather than running a second full turn. respond and respond_stream both claim/await; a finally always releases waiters. Short window so a genuine later resend still runs fresh. Verified with a threaded race: two simultaneous calls, body runs once, both get the same reply. Also fixes the duplicate user-message persistence (the same double-execution) that was polluting reconstructed history. 6 dedup tests + concurrency check; suite 232. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
110 lines
5.2 KiB
Python
110 lines
5.2 KiB
Python
"""record_hand idempotency + straddle parse coverage.
|
|
|
|
The chat turn can execute twice — the SSE stream and the blocking fallback both run
|
|
server-side (two 'chat request' lines, 1s apart) — which double-logged the same hand
|
|
once logging became guaranteed. A system-of-record must record an event once."""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def poker(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
|
from lyra import llm
|
|
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
|
import lyra.memory as memory
|
|
importlib.reload(memory)
|
|
import lyra.poker as poker
|
|
importlib.reload(poker)
|
|
return poker
|
|
|
|
|
|
_PARSED = {
|
|
"game": "NLH", "hero_pos": "SB", "hero_cards": ["Ah", "Kh"],
|
|
"board": ["Kd", "9d", "4c", "2s"], "players": [], "actions": [],
|
|
"result": {"hero_net": -200, "pot": 400},
|
|
}
|
|
|
|
|
|
def test_record_hand_is_idempotent_across_double_execution(poker, monkeypatch):
|
|
sid = poker.start_session(venue="Borgata", stakes="1/3", buy_in=400)
|
|
monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: dict(_PARSED))
|
|
first = poker.record_hand("i have AhKh in the SB, btn straddle, ...")
|
|
second = poker.record_hand("i have AhKh in the SB, btn straddle, ...") # the duplicate turn
|
|
assert first["id"] == second["id"]
|
|
assert second.get("deduped") is True
|
|
assert len(poker.list_hands(sid)) == 1 # ledger holds ONE, not two
|
|
|
|
|
|
def test_record_hand_does_not_dedupe_a_genuinely_different_hand(poker, monkeypatch):
|
|
sid = poker.start_session(venue="Borgata", stakes="1/3", buy_in=400)
|
|
monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: dict(_PARSED))
|
|
poker.record_hand("hand one")
|
|
other = dict(_PARSED, hero_cards=["Qs", "Qd"], board=["Qh", "7c", "2s"])
|
|
monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: dict(other))
|
|
poker.record_hand("a different hand entirely")
|
|
assert len(poker.list_hands(sid)) == 2 # distinct hands both land
|
|
|
|
|
|
def test_dedupe_handles_boardless_hand(poker, monkeypatch):
|
|
# NULL-safe match: a preflop-only hand (no board) still dedupes.
|
|
sid = poker.start_session(venue="Borgata", buy_in=400)
|
|
preflop = {"game": "NLH", "hero_pos": "BTN", "hero_cards": ["As", "Ks"],
|
|
"board": [], "players": [], "actions": [], "result": {"hero_net": 30}}
|
|
monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: dict(preflop))
|
|
a = poker.record_hand("AKs btn, i open everyone folds")
|
|
b = poker.record_hand("AKs btn, i open everyone folds")
|
|
assert a["id"] == b["id"] and len(poker.list_hands(sid)) == 1
|
|
|
|
|
|
def test_parse_prompt_records_straddles():
|
|
from lyra import poker as pk
|
|
p = pk._HAND_PARSE_PROMPT.lower()
|
|
assert "straddle" in p and "button straddle" in p
|
|
assert "acts last preflop" in p or "act last preflop" in p
|
|
|
|
|
|
# --- hero stack auto-fill from the last logged stack ----------------------
|
|
|
|
def test_hero_stack_filled_from_last_stack_log(poker, monkeypatch):
|
|
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
|
poker.log_stack(275) # his last reported stack
|
|
monkeypatch.setattr(poker, "parse_hand",
|
|
lambda *a, **k: {"game": "NLH", "hero_involved": True,
|
|
"hero_pos": "CO", "hero_cards": ["As", "Ks"],
|
|
"board": ["2c"], "players": [], "actions": [],
|
|
"result": {"hero_net": 50}})
|
|
out = poker.record_hand("AKs in the CO, i raise, flop 2c...")
|
|
stored = poker.get_hand(out["id"])["structured"]
|
|
hero = next(pl for pl in stored["players"] if pl.get("hero"))
|
|
assert hero["stack"] == 275 and hero.get("stack_inferred") is True
|
|
|
|
|
|
def test_stated_stack_is_never_overridden(poker, monkeypatch):
|
|
poker.start_session(venue="Meadows", buy_in=400)
|
|
poker.log_stack(275)
|
|
monkeypatch.setattr(poker, "parse_hand",
|
|
lambda *a, **k: {"game": "NLH", "hero_involved": True,
|
|
"hero_pos": "BTN", "hero_cards": ["Qh", "Qd"],
|
|
"players": [{"pos": "BTN", "stack": 500}],
|
|
"board": [], "actions": [], "result": {}})
|
|
out = poker.record_hand("500 deep on the btn with QQ")
|
|
hero = next(pl for pl in poker.get_hand(out["id"])["structured"]["players"]
|
|
if pl.get("pos") == "BTN")
|
|
assert hero["stack"] == 500 and not hero.get("stack_inferred")
|
|
|
|
|
|
def test_observed_hand_gets_no_hero_stack(poker, monkeypatch):
|
|
poker.start_session(venue="Meadows", buy_in=400)
|
|
poker.log_stack(275)
|
|
monkeypatch.setattr(poker, "parse_hand",
|
|
lambda *a, **k: {"game": "NLH", "hero_involved": False,
|
|
"hero_pos": None, "hero_cards": [],
|
|
"players": [{"pos": "CO", "cards": ["Kx", "Kx"]}],
|
|
"board": [], "actions": [], "result": {}})
|
|
out = poker.record_hand("the CO stacked off KK vs the nit")
|
|
assert all(not pl.get("stack_inferred") for pl in poker.get_hand(out["id"])["structured"]["players"])
|