feat: session modes (Talk/Cash) + live session HUD
Lyra now switches register based on what she's doing at the table instead of
being a wishy-washy companion mid-session.
Modes (lyra/modes.py):
- Talk (default companion) + Cash (live cash copilot); a mode = prompt card +
tool allow-list. Tool gating via tools.specs(allow=).
- Two-register Cash voice: act-first one-line logging when fed facts; full warm
companion voice for strategy / tilt / mental game.
- mode persisted per chat session (new sessions.mode column); auto-switch into
Cash when start_session fires; UI forces cloud backend in Cash (tools only
fire there).
Stack tracking + HUD:
- log_stack tool + poker_stack_log table; live net while sitting (stack - buy-in).
- poker.hud() bundle; /session HUD page (stack sparkline, hands, villains, notes,
stats) polling /session/data every 5s; Talk/Cash switcher + Session nav.
Endpoints: /session, /session/data, GET/POST /sessions/{id}/mode, /modes.
tests/test_modes.py (gating, mode roundtrip, stack/HUD); 36 tests green. v0.3.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""Conversation modes: tool gating, mode persistence, stack tracking + HUD."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lyra(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)
|
||||
import lyra.modes as modes
|
||||
importlib.reload(modes)
|
||||
import lyra.tools as tools
|
||||
importlib.reload(tools)
|
||||
return memory, poker, modes, tools
|
||||
|
||||
|
||||
def _names(specs):
|
||||
return {s["function"]["name"] for s in specs}
|
||||
|
||||
|
||||
def test_tool_gating_by_mode(lyra):
|
||||
_, _, modes, tools = lyra
|
||||
talk = _names(tools.specs(modes.TALK.tools))
|
||||
cash = _names(tools.specs(modes.CASH.tools))
|
||||
|
||||
# Cash is the full live toolset.
|
||||
assert {"log_hand", "log_stack", "analyze_spot", "end_session"} <= cash
|
||||
# Talk hides the live write tools...
|
||||
assert "log_hand" not in talk and "log_stack" not in talk
|
||||
# ...but keeps her agency + read-only lookups + the session entry point.
|
||||
assert {"journal_write", "note", "player_profile", "start_session"} <= talk
|
||||
# No allow-list = every registered tool.
|
||||
assert _names(tools.specs()) == set(tools.TOOLS)
|
||||
|
||||
|
||||
def test_every_mode_tool_exists(lyra):
|
||||
_, _, modes, tools = lyra
|
||||
for mode in modes.MODES.values():
|
||||
assert set(mode.tools) <= set(tools.TOOLS), f"{mode.key} references unknown tools"
|
||||
|
||||
|
||||
def test_mode_resolution_and_persistence(lyra):
|
||||
memory, _, modes, _ = lyra
|
||||
assert modes.get(None).key == modes.DEFAULT
|
||||
assert modes.get("nonsense").key == modes.DEFAULT
|
||||
assert modes.get("poker_cash") is modes.CASH
|
||||
|
||||
memory.ensure_session("s1")
|
||||
assert memory.get_session_mode("s1") is None # unset -> caller applies default
|
||||
memory.set_session_mode("s1", "poker_cash")
|
||||
assert memory.get_session_mode("s1") == "poker_cash"
|
||||
# set on an unknown session creates the row
|
||||
memory.set_session_mode("s2", "conversation")
|
||||
assert memory.get_session_mode("s2") == "conversation"
|
||||
|
||||
|
||||
def test_stack_log_and_live_net(lyra):
|
||||
_, poker, _, _ = lyra
|
||||
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
||||
assert poker.current_stack() is None # nothing logged yet
|
||||
|
||||
st = poker.log_stack(700)
|
||||
assert st["current"] == 700 and st["net"] == 200 # up 200 on a 500 buy-in
|
||||
poker.log_stack(350)
|
||||
assert poker.current_stack() == 350
|
||||
assert poker.stack_state()["net"] == -150
|
||||
assert len(poker.stack_log()) == 2
|
||||
|
||||
|
||||
def test_log_stack_requires_live_session(lyra):
|
||||
_, poker, _, _ = lyra
|
||||
with pytest.raises(ValueError):
|
||||
poker.log_stack(300)
|
||||
|
||||
|
||||
def test_hud_bundle(lyra):
|
||||
_, poker, _, _ = lyra
|
||||
assert poker.hud() is None # no session -> nothing to show
|
||||
|
||||
sid = poker.start_session(venue="Meadows", stakes="2/5", game="NLH", buy_in=500)
|
||||
poker.log_stack(620)
|
||||
poker.log_hand(position="BTN", hole_cards="AKs", result=120, tag="confidence")
|
||||
poker.add_read(note="3bets light from the SB", name="Round Mike", seat="SB")
|
||||
|
||||
hud = poker.hud()
|
||||
assert hud["session"]["id"] == sid and hud["session"]["stakes"] == "2/5"
|
||||
assert hud["stack"]["current"] == 620 and hud["stack"]["net"] == 120
|
||||
assert len(hud["stack"]["log"]) == 1
|
||||
assert len(hud["hands"]) == 1 and hud["hands"][0]["hole_cards"] == "AKs"
|
||||
assert any(v["name"] == "Round Mike" for v in hud["villains"])
|
||||
assert hud["stats"]["hands_logged"] == 1
|
||||
|
||||
|
||||
def test_log_stack_tool_handler(lyra):
|
||||
_, poker, _, tools = lyra
|
||||
poker.start_session(stakes="1/3", buy_in=300)
|
||||
out = tools.dispatch("log_stack", {"amount": 450}, {})
|
||||
assert "450" in out and "150" in out # confirms stack + live net
|
||||
# graceful when there's no number
|
||||
assert "number" in tools.dispatch("log_stack", {}, {}).lower()
|
||||
Reference in New Issue
Block a user