uro-cli 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: uro-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Uro Engine reference client — play, dry-run, and dev tools against the engine
5
5
  Project-URL: Homepage, https://github.com/cupskeee/uro
6
6
  Project-URL: Repository, https://github.com/cupskeee/uro
@@ -12,8 +12,8 @@ Requires-Python: >=3.12
12
12
  Requires-Dist: python-dotenv>=1.0
13
13
  Requires-Dist: rich>=13.9
14
14
  Requires-Dist: typer>=0.27.2
15
- Requires-Dist: uro-core[llm,postgres]==0.7.0
16
- Requires-Dist: uro-server==0.7.0
15
+ Requires-Dist: uro-core[llm,postgres]==0.8.0
16
+ Requires-Dist: uro-server==0.8.0
17
17
  Requires-Dist: websockets>=16.1
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uro-cli"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Uro Engine reference client — play, dry-run, and dev tools against the engine"
5
5
  license = "MIT"
6
6
  license-files = ["LICENSE"]
@@ -13,8 +13,8 @@ requires-python = ">=3.12"
13
13
  # pair a new uro-cli with an old uro-core; `just release` bumps all three together, and the
14
14
  # `{workspace = true}` sources make a drifted pin fail `uv sync` locally.
15
15
  dependencies = [
16
- "uro-core[postgres,llm]==0.7.0",
17
- "uro-server==0.7.0",
16
+ "uro-core[postgres,llm]==0.8.0",
17
+ "uro-server==0.8.0",
18
18
  "typer>=0.27.2",
19
19
  "rich>=13.9",
20
20
  "websockets>=16.1",
@@ -0,0 +1,133 @@
1
+ """`uro connect`, the reference play client, against a real server on a local port.
2
+
3
+ Its prompt used to come back the moment an intent was sent: it sat above the streaming narration,
4
+ and nothing was on screen while the beat was still being extracted and committed, so a player in
5
+ the first live run (2026-10-31) retyped an intent and played it twice.
6
+ """
7
+
8
+ import asyncio
9
+ import socket
10
+ import threading
11
+ import time
12
+ from collections.abc import AsyncIterator
13
+
14
+ import uvicorn
15
+ from typer.testing import CliRunner
16
+ from uro_cli.main import app
17
+ from uro_server.app import ServerDeps, create_app
18
+
19
+
20
+ def _free_port() -> int:
21
+ with socket.socket() as s:
22
+ s.bind(("127.0.0.1", 0))
23
+ return int(s.getsockname()[1])
24
+
25
+
26
+ def test_the_prompt_waits_for_each_beat_to_settle() -> None:
27
+ started: list[str] = []
28
+
29
+ async def run_beat(campaign_id: str, participant: str, text: str) -> AsyncIterator[str]:
30
+ started.append(text)
31
+ for word in ("The ", "tide ", "turns."):
32
+ await asyncio.sleep(0.05)
33
+ yield word
34
+ await asyncio.sleep(0.3) # extraction: the narration is done, the beat isn't
35
+
36
+ async def campaign_exists(campaign_id: str) -> bool:
37
+ return True
38
+
39
+ deps = ServerDeps(
40
+ resolve_participant={"tok": "player-1"}.get,
41
+ campaign_exists=campaign_exists,
42
+ run_beat=run_beat,
43
+ )
44
+ port = _free_port()
45
+ server = uvicorn.Server(
46
+ uvicorn.Config(create_app(deps), host="127.0.0.1", port=port, log_level="warning")
47
+ )
48
+ thread = threading.Thread(target=server.run, daemon=True)
49
+ thread.start()
50
+ while not server.started:
51
+ time.sleep(0.02)
52
+ try:
53
+ result = CliRunner().invoke(
54
+ app,
55
+ ["connect", "camp-1", "--server", f"http://127.0.0.1:{port}", "--token", "tok"],
56
+ input="first\nsecond\n/quit\n", # typed ahead, as an impatient player would
57
+ )
58
+ finally:
59
+ server.should_exit = True
60
+ thread.join(5)
61
+ out = result.output
62
+ assert result.exit_code == 0, out
63
+ assert started == ["first", "second"]
64
+ prompts = [i for i in range(len(out)) if out.startswith("> ", i)]
65
+ first, second = out.index("✓ [player-1] 'first'"), out.index("✓ [player-1] 'second'")
66
+ assert len(prompts) == 3, out
67
+ assert first < prompts[1] < second < prompts[2], out # each prompt only after its beat settled
68
+
69
+
70
+ def test_another_players_identical_intent_never_settles_ours() -> None:
71
+ # Review: the client matched outcomes by intent TEXT. In a party, another player typing the same
72
+ # words while our beat ran got `not_your_turn`, broadcast to everyone, and released our prompt
73
+ # mid-beat. Intents now carry an id the server echoes back.
74
+ import json
75
+
76
+ import websockets
77
+ from uro_core.session import PartyArbiter
78
+
79
+ async def run_beat(campaign_id: str, participant: str, text: str) -> AsyncIterator[str]:
80
+ yield "The "
81
+ await asyncio.sleep(1.2)
82
+ yield "end."
83
+
84
+ async def campaign_exists(campaign_id: str) -> bool:
85
+ return True
86
+
87
+ deps = ServerDeps(
88
+ resolve_participant={"tok": "player-1", "tok2": "player-2"}.get,
89
+ campaign_exists=campaign_exists,
90
+ run_beat=run_beat,
91
+ )
92
+ port = _free_port()
93
+ server = uvicorn.Server(
94
+ uvicorn.Config(
95
+ create_app(deps, arbiter=PartyArbiter()),
96
+ host="127.0.0.1",
97
+ port=port,
98
+ log_level="warning",
99
+ )
100
+ )
101
+ thread = threading.Thread(target=server.run, daemon=True)
102
+ thread.start()
103
+ while not server.started:
104
+ time.sleep(0.02)
105
+
106
+ def player_two() -> None: # joins second (so player-1 holds the turn), same words mid-beat
107
+ async def go() -> None:
108
+ await asyncio.sleep(0.3)
109
+ uri = f"ws://127.0.0.1:{port}/campaigns/c/play?token=tok2"
110
+ async with websockets.connect(uri) as ws:
111
+ await asyncio.sleep(0.2)
112
+ await ws.send(json.dumps({"type": "intent", "text": "look around"}))
113
+ await asyncio.sleep(1.5)
114
+
115
+ asyncio.run(go())
116
+
117
+ other = threading.Thread(target=player_two)
118
+ other.start()
119
+ try:
120
+ result = CliRunner().invoke(
121
+ app,
122
+ ["connect", "c", "--server", f"http://127.0.0.1:{port}", "--token", "tok"],
123
+ input="look around\n/quit\n",
124
+ )
125
+ finally:
126
+ other.join()
127
+ server.should_exit = True
128
+ thread.join(5)
129
+ out = result.output
130
+ assert "not your turn" in out, out # player-2's identical intent did reach us...
131
+ ours = out.index("✓ [player-1] 'look around'") # ...but our prompt waited for OUR beat
132
+ prompts = [i for i in range(len(out)) if out.startswith("> ", i)]
133
+ assert len(prompts) == 2 and prompts[1] > ours, out
@@ -24,7 +24,7 @@ from uro_core.providers.router import ProviderRouter
24
24
  from uro_core.rulesets import registry
25
25
  from uro_core.rulesets.base import CharSpec, Ruleset
26
26
  from uro_core.rulesets.rng import Rng
27
- from uro_core.timeline.models import STARTING_GEAR, World
27
+ from uro_core.timeline.models import FORK_TIME_SKIP, STARTING_GEAR, World
28
28
 
29
29
  from uro_cli.wiring import (
30
30
  build_router,
@@ -653,20 +653,24 @@ def serve(
653
653
  from uro_core.adapters.nats import NatsCommitFeed
654
654
 
655
655
  commit_feed = NatsCommitFeed(nats_url, subject_prefix=subject_prefix)
656
- fastapi_app = create_app(
657
- deps, arbiter=arbiter, cors_origins=cors_origin or None, commit_feed=commit_feed
658
- )
659
656
 
660
- @fastapi_app.on_event("startup")
661
657
  async def _startup() -> None:
662
658
  await connect_store(store)
663
659
  if deps.hydrate_tokens is not None: # load durable runtime tokens (D-39) — AFTER connect
664
660
  await deps.hydrate_tokens()
665
661
 
666
- @fastapi_app.on_event("shutdown")
667
662
  async def _shutdown() -> None:
668
663
  await store.close()
669
664
 
665
+ fastapi_app = create_app(
666
+ deps,
667
+ arbiter=arbiter,
668
+ cors_origins=cors_origin or None,
669
+ commit_feed=commit_feed,
670
+ startup=_startup,
671
+ shutdown=_shutdown,
672
+ )
673
+
670
674
  # (The DB preflight is folded into _resolve_router above — its connect_store gives the same
671
675
  # clean docker-first hint on a down DB, and the pool it opens is closed before uvicorn starts;
672
676
  # the startup hook re-opens one on uvicorn's own loop.)
@@ -697,6 +701,7 @@ def connect(
697
701
  the campaign stream in live."""
698
702
  import asyncio
699
703
  import json
704
+ import uuid
700
705
 
701
706
  import websockets
702
707
 
@@ -707,11 +712,44 @@ def connect(
707
712
  try:
708
713
  async with websockets.connect(uri) as ws:
709
714
  typer.echo(f"connected to {campaign_id} as {token!r}. Type an action; /quit.\n")
715
+ # An intent's beat is SETTLED by one of these, each echoing the intent's text. The
716
+ # prompt waits for it: shown at once, it sat above the streaming narration and was
717
+ # gone by the time the beat committed (extraction runs after the narration), so a
718
+ # player retyped the intent and played it twice (live run, 2026-10-31).
719
+ settles = {
720
+ "beat_committed",
721
+ "beat_failed",
722
+ "not_your_turn",
723
+ "proposal_opened",
724
+ "intent_rejected",
725
+ }
726
+ # intent id → (its text, set when it settles). The server echoes the id as
727
+ # `intent_id`, so another player's identical text never settles ours. Only a server
728
+ # that has never echoed one (an older one) is matched by text; once one has, a frame
729
+ # without an id is another client's.
730
+ pending: dict[str, tuple[str, asyncio.Event]] = {}
731
+ echoes = False
732
+
733
+ def settle(msg: dict[str, Any]) -> None:
734
+ if "intent_id" in msg:
735
+ hit = pending.pop(str(msg["intent_id"]), None)
736
+ elif not echoes:
737
+ text = str(msg.get("intent", msg.get("text", "")))
738
+ rid = next((k for k, (t, _) in pending.items() if t == text), None)
739
+ hit = pending.pop(rid) if rid is not None else None
740
+ else:
741
+ hit = None
742
+ if hit is not None:
743
+ hit[1].set()
710
744
 
711
745
  async def receive() -> None:
746
+ nonlocal echoes
712
747
  async for raw in ws:
713
748
  msg = json.loads(raw)
714
749
  kind = msg.get("type")
750
+ echoes = echoes or "intent_id" in msg
751
+ if kind in settles:
752
+ settle(msg)
715
753
  if kind == "narration_chunk":
716
754
  typer.echo(msg["text"], nl=False)
717
755
  elif kind == "beat_committed":
@@ -740,6 +778,10 @@ def connect(
740
778
  )
741
779
  elif kind == "beat_failed":
742
780
  typer.echo(f"\n ✗ beat failed: {msg['error']}")
781
+ elif kind == "intent_rejected":
782
+ typer.echo(
783
+ f" ✗ [{msg['participant_id']}] intent rejected: {msg['text']!r}"
784
+ )
743
785
  elif kind == "commit": # made elsewhere: another server, the CLI (D-67)
744
786
  for beat in msg.get("beats") or []:
745
787
  typer.echo(f"\n[{beat['participant_id']}] {beat['narration']}")
@@ -759,7 +801,20 @@ def connect(
759
801
  elif intent.startswith("/vote "): # non-canon consensus vote (D-38)
760
802
  await ws.send(json.dumps({"type": "vote", "choice": intent[6:]}))
761
803
  elif intent:
762
- await ws.send(json.dumps({"type": "intent", "text": intent}))
804
+ rid = uuid.uuid4().hex
805
+ settled = asyncio.Event()
806
+ pending[rid] = (intent, settled)
807
+ await ws.send(json.dumps({"type": "intent", "text": intent, "id": rid}))
808
+ waiting = asyncio.ensure_future(settled.wait())
809
+ # a slow beat (a reasoning model's extraction) still settles well inside
810
+ # this; past it — or if the server goes away — the prompt comes back anyway
811
+ await asyncio.wait(
812
+ {waiting, receiver}, timeout=600, return_when=asyncio.FIRST_COMPLETED
813
+ )
814
+ waiting.cancel()
815
+ pending.pop(rid, None)
816
+ if receiver.done(): # the server closed the connection
817
+ break
763
818
  receiver.cancel()
764
819
  except OSError as exc:
765
820
  typer.echo(f"could not connect to {server}: {exc} (is `uro serve` running?)", err=True)
@@ -964,7 +1019,7 @@ def branch_fork(
964
1019
  pick = registry.resolve_for_branch(store, branch.branch_id)
965
1020
  ruleset = await _time_skip_ruleset(pick)
966
1021
  engine = Engine(store, build_router("stub", None), ruleset=ruleset)
967
- await engine.agenda_tick(branch.branch_id, time_skip_days)
1022
+ await engine.agenda_tick(branch.branch_id, time_skip_days, reason=FORK_TIME_SKIP)
968
1023
  finally:
969
1024
  await store.close()
970
1025
  forked = branch.forked_from if branch.forked_from else "-"
@@ -1279,6 +1334,7 @@ def calls(
1279
1334
  typer.echo(
1280
1335
  f"{r['created_at']:%Y-%m-%d %H:%M:%S} {r['stage_tag']:<10} {r['model'] or '-'} "
1281
1336
  f"{tokens} {r['latency_ms']}ms"
1337
+ + (f" FAILED: {r['error']}" if r.get("error") else "")
1282
1338
  )
1283
1339
  if r["response"] is not None:
1284
1340
  typer.echo(f" {r['response']}")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes