From 906ba423d8cd18bf8e1345b69c07d2c14790543b Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Sun, 2 Aug 2026 20:44:06 +0530 Subject: [PATCH] Keep every retried attempt instead of deleting it Retry used to delete the last AI action and generate a replacement, so the discarded narration was simply gone. The row survives now: each attempt is appended to actions.variants with variant_index naming the live one, and a ChatGPT-style pager under the message browses them. Action.text still mirrors the active variant, so the context builder, memory bank, summarizer and export needed no changes. A variant carries only what differs between attempts -- the text, the reasoning, and the state it produced -- never the assembled prompt, which is identical across attempts of one turn and is the bulk of context_snapshot. Only the last message can be switched, restoring the script/world state that attempt produced; earlier turns were written as a continuation of whatever is active there, so theirs are read-only previews. Three things that would otherwise bite: - generate_turn now wraps _generate_turn and watches for a save sentinel. If the generator ends without it (provider error, empty reply, script stop, client hangup) it re-applies the previous variant -- otherwise a failed retry leaves rolled-back stats under un-rolled-back text. - Retry reuses the turn's own index rather than next_index, or the clock the world-state cooldowns run on advances on a re-run of the same turn. - Editing a message rewrites the active variant too, or paging away and back silently reverts the edit. Migrations 34/35 verified as an upgrade against a populated database, not just a fresh schema. test_state_revert's retry test asserted the old delete behaviour and was rewritten. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet --- backend/app/migrations.py | 6 + backend/app/models.py | 13 ++ backend/app/routers/adventures.py | 255 +++++++++++++++++++-- backend/app/schemas.py | 17 ++ backend/tests/test_retry_variants.py | 323 +++++++++++++++++++++++++++ backend/tests/test_state_revert.py | 12 +- frontend/src/api.js | 8 + frontend/src/index.css | 37 +++ frontend/src/pages/Play.jsx | 85 ++++++- 9 files changed, 734 insertions(+), 22 deletions(-) create mode 100644 backend/tests/test_retry_variants.py diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 1c54e8a..bd01445 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -104,6 +104,12 @@ MIGRATIONS: list[tuple[int, str]] = [ # cards and leave player-authored ones alone. NULL = player-authored, or a # copy predating this column (matched by name once, then adopted). (33, "ALTER TABLE story_cards ADD COLUMN source_ref VARCHAR(64)"), + # Retry history: every attempt made for an AI turn, oldest first, so retry + # can append instead of deleting. NULL = never retried (the row is its own + # only version), which is also the correct reading for every action that + # predates this column. + (34, "ALTER TABLE actions ADD COLUMN variants JSON"), + (35, "ALTER TABLE actions ADD COLUMN variant_index INTEGER NOT NULL DEFAULT 0"), ] LATEST_VERSION = max((v for v, _ in MIGRATIONS), default=1) diff --git a/backend/app/models.py b/backend/app/models.py index 8634d96..11823be 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -212,6 +212,13 @@ class Action(Base): state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Phase 12: same idea for the RPG world_state, so undo/retry rolls it back too. world_state_before: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # Retry history (AI actions): every attempt made for this turn, oldest + # first, INCLUDING the active one. NULL/empty means never retried — the row + # is its own only version. `variant_index` says which entry `text`, + # `reasoning` and `context_snapshot` currently mirror; retry appends and + # points here instead of deleting the row, so nothing is lost. + variants: Mapped[list | None] = mapped_column(JSON, nullable=True) + variant_index: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) adventure: Mapped[Adventure] = relationship(back_populates="actions") @@ -241,6 +248,12 @@ class Action(Base): out.append({"kind": "stat", "label": label, "delta": delta, "value": new}) return out + @property + def variant_count(self) -> int: + """How many attempts exist for this turn. 0 (not 1) when the action was + never retried — the UI shows its pager only above 1 either way.""" + return len(self.variants) if isinstance(self.variants, list) else 0 + class Script(Base): __tablename__ = "scripts" diff --git a/backend/app/routers/adventures.py b/backend/app/routers/adventures.py index 18e97a6..6e96113 100644 --- a/backend/app/routers/adventures.py +++ b/backend/app/routers/adventures.py @@ -319,6 +319,63 @@ def snapshot_world_state(adventure: models.Adventure) -> dict: return copy.deepcopy(state) +# ---------- Retry history (variants) ---------- +# +# Retry used to delete the AI action and generate a replacement. Now the row +# survives and every attempt is appended to `Action.variants`, with +# `variant_index` naming the live one. A variant carries only the parts that +# actually differ between attempts — the narration and the state it produced — +# never the assembled prompt, which is identical across attempts of one turn +# and is by far the biggest thing in `context_snapshot`. + +# The per-attempt slices of context_snapshot. Everything else in the snapshot +# (system/story/memories) is shared by every attempt at the same turn. +VARIANT_SNAPSHOT_KEYS = ("world_state", "script", "raw_output") + + +def variant_of(action: models.Action, adventure: models.Adventure) -> dict: + """Freeze an action's *current* content as a variant entry. + + `adventure` supplies the resulting script/world state, so this must be + called before any rollback — those live values are this attempt's outcome. + """ + snapshot = action.context_snapshot if isinstance(action.context_snapshot, dict) else {} + entry = { + "text": action.text, + "reasoning": action.reasoning, + "script_state": snapshot_state(adventure), + "created_at": action.created_at.isoformat() if action.created_at else None, + } + for key in VARIANT_SNAPSHOT_KEYS: + if key in snapshot: + entry[key] = copy.deepcopy(snapshot[key]) + return entry + + +def apply_variant(action: models.Action, adventure: models.Adventure, index: int) -> None: + """Make variant `index` the live one: its text onto the action, its + outcome back onto the adventure.""" + entry = action.variants[index] + action.text = entry.get("text", "") + action.reasoning = entry.get("reasoning") + snapshot = dict(action.context_snapshot) if isinstance(action.context_snapshot, dict) else {} + for key in VARIANT_SNAPSHOT_KEYS: + if key in entry: + snapshot[key] = copy.deepcopy(entry[key]) + else: + snapshot.pop(key, None) + action.context_snapshot = snapshot + action.variant_index = index + if isinstance(entry.get("script_state"), dict): + adventure.script_state = copy.deepcopy(entry["script_state"]) + # The world state this attempt left behind lives inside its own snapshot + # slice; absent for adventures with no RPG layer, where there's nothing to + # restore anyway. + world_state = (entry.get("world_state") or {}).get("state") + if isinstance(world_state, dict): + adventure.world_state = copy.deepcopy(world_state) + + @router.patch("/{adventure_id}", response_model=schemas.AdventureOut) def update_adventure( adventure_id: int, @@ -409,9 +466,44 @@ async def generate_turn( db: Session, pipeline: ScriptPipeline, user: models.User, + retry_of: models.Action | None = None, ): """SSE generator: streams the AI continuation through the context/output - script hooks, then stores the result.""" + script hooks, then stores the result. + + With `retry_of`, the result is appended as a new variant of that existing + AI action rather than stored as a new one — the discarded attempt stays + readable. The caller must have seeded `retry_of.variants` and rolled the + adventure back first (see `retry_action`); if this generator ends without + saving, that rollback is undone so state can't drift from the text still + on screen.""" + saved = False + try: + async for event in _generate_turn(adventure, db, pipeline, user, retry_of): + if event is _SAVED: + saved = True + continue + yield event + finally: + if retry_of is not None and not saved: + # Provider error, empty reply, a script stop, or the client hanging + # up: put the attempt we rolled away from back in charge. + apply_variant(retry_of, adventure, retry_of.variant_index) + db.commit() + + +# Sentinel yielded by _generate_turn once the action is committed, so the +# wrapper above knows the rollback must stand rather than be reversed. +_SAVED = object() + + +async def _generate_turn( + adventure: models.Adventure, + db: Session, + pipeline: ScriptPipeline, + user: models.User, + retry_of: models.Action | None = None, +): settings = get_settings(db, user) cfg = auth.resolve_provider_config(settings) if cfg.using_demo: @@ -495,7 +587,9 @@ async def generate_turn( # RPG world state (Phase 12): pull the AI's state delta out of the reply, # let the engine referee it, and strip the block from the shown text. - ai_index = next_index(adventure) + # A retry re-runs the *same* turn, so it keeps that turn's index — using + # next_index here would advance the clock the cooldown rules run on. + ai_index = retry_of.index if retry_of is not None else next_index(adventure) stat_schema = adventure.scenario.stat_schema if adventure.scenario else None if worldstate.has_schema(stat_schema): text, delta = worldstate.extract_delta(text) @@ -510,17 +604,34 @@ async def generate_turn( snapshot["raw_output"] = raw_output - ai_action = models.Action( - adventure_id=adventure.id, - index=ai_index, - type="ai", - text=text, - reasoning="".join(reasoning_chunks).strip() or None, - context_snapshot=snapshot, - state_before=state_before, - world_state_before=world_state_before, - ) - db.add(ai_action) + reasoning = "".join(reasoning_chunks).strip() or None + if retry_of is not None: + # Same row, one more attempt. Writing text/reasoning/snapshot through + # the variant list keeps the row and its history in step. + ai_action = retry_of + ai_action.context_snapshot = snapshot + history = list(ai_action.variants or []) + history.append({ + "text": text, + "reasoning": reasoning, + "script_state": snapshot_state(adventure), + "created_at": models.utcnow().isoformat(), + **{k: copy.deepcopy(snapshot[k]) for k in VARIANT_SNAPSHOT_KEYS if k in snapshot}, + }) + ai_action.variants = history + apply_variant(ai_action, adventure, len(history) - 1) + else: + ai_action = models.Action( + adventure_id=adventure.id, + index=ai_index, + type="ai", + text=text, + reasoning=reasoning, + context_snapshot=snapshot, + state_before=state_before, + world_state_before=world_state_before, + ) + db.add(ai_action) adventure.updated_at = models.utcnow() if cfg.using_demo: # Successful demo turns count against the daily cap (checked up front @@ -528,6 +639,7 @@ async def generate_turn( auth.count_demo_turn(user) db.commit() db.refresh(ai_action) + yield _SAVED yield sse({"type": "done", "action": action_json(ai_action), "script": pipeline.report()}) # Phase 6: fire-and-forget summarization/embedding (opens its own DB # session). Skipped on the demo key — background AI calls would be @@ -617,14 +729,26 @@ def retry_action( db: Session = Depends(get_db), user: models.User = CurrentUser, ): - """Delete the last AI action and regenerate from the same input.""" + """Regenerate the last AI action, keeping the discarded attempt. + + The row survives: its current content is frozen as a variant, the shared + script/world state rolls back to the pre-turn snapshot, and the new attempt + is appended as the next variant. Nothing the AI wrote is ever thrown away. + """ adventure = get_adventure_or_404(adventure_id, db, user) limits.rate_limit("turn", request, user) check_demo_cap(db, user) acquire_turn_lock(adventure_id) + last_ai = None try: if adventure.actions and adventure.actions[-1].type == "ai": last_ai = adventure.actions[-1] + # First retry: the row has no history yet, so record what's on + # screen as variant 0 before anything is rolled back — the live + # script/world state is precisely that attempt's outcome. + if not last_ai.variants: + last_ai.variants = [variant_of(last_ai, adventure)] + last_ai.variant_index = 0 # Roll the scoreboard back to before this AI turn's hooks ran, so # regenerating starts fresh instead of stacking output mutations on # top of the discarded attempt. NULL for pre-migration actions. @@ -632,7 +756,6 @@ def retry_action( adventure.script_state = copy.deepcopy(last_ai.state_before) if last_ai.world_state_before is not None: adventure.world_state = copy.deepcopy(last_ai.world_state_before) - db.delete(last_ai) db.commit() db.refresh(adventure) except BaseException: @@ -641,13 +764,87 @@ def retry_action( return StreamingResponse( with_turn_lock( adventure_id, - generate_turn(adventure, db, ScriptPipeline(adventure, db), user), + generate_turn( + adventure, db, ScriptPipeline(adventure, db), user, retry_of=last_ai + ), ), media_type="text/event-stream", headers=SSE_HEADERS, ) +@router.get( + "/{adventure_id}/actions/{action_id}/variants", + response_model=list[schemas.VariantOut], +) +def list_variants( + adventure_id: int, + action_id: int, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Every attempt made for one AI turn. Fetched on demand — the adventure + payload carries only the counts, so old narration doesn't ride along on + every page load.""" + get_adventure_or_404(adventure_id, db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + variants = action.variants if isinstance(action.variants, list) else [] + return [ + schemas.VariantOut( + index=i, + text=entry.get("text", ""), + reasoning=entry.get("reasoning"), + created_at=entry.get("created_at"), + active=(i == action.variant_index), + ) + for i, entry in enumerate(variants) + ] + + +@router.post( + "/{adventure_id}/actions/{action_id}/variant", response_model=schemas.ActionOut +) +def select_variant( + adventure_id: int, + action_id: int, + payload: schemas.VariantSelect, + db: Session = Depends(get_db), + user: models.User = CurrentUser, +): + """Make an earlier attempt the live one again, restoring the script/world + state it produced. + + Only the last action can be switched: the turns after an older one were + written as a continuation of the text that's currently active, so swapping + it out from underneath them would leave the story contradicting itself. + Earlier turns' attempts stay readable through `list_variants`. + """ + adventure = get_adventure_or_404(adventure_id, db, user) + action = db.get(models.Action, action_id) + if action is None or action.adventure_id != adventure_id: + raise HTTPException(404, "Action not found") + variants = action.variants if isinstance(action.variants, list) else [] + if not 0 <= payload.index < len(variants): + raise HTTPException(400, "No such attempt for this action") + if not adventure.actions or adventure.actions[-1].id != action.id: + raise HTTPException( + 400, + "Only the latest message can be switched — the story has already " + "continued from this one.", + ) + acquire_turn_lock(adventure_id) + try: + apply_variant(action, adventure, payload.index) + adventure.updated_at = models.utcnow() + db.commit() + db.refresh(action) + return action + finally: + _active_turns.discard(adventure_id) + + @router.post("/{adventure_id}/undo", response_model=list[schemas.ActionOut]) def undo_turn( adventure_id: int, db: Session = Depends(get_db), user: models.User = CurrentUser @@ -730,6 +927,15 @@ def export_adventure( { "index": a.index, "type": a.type, "text": a.text, "reasoning": a.reasoning, + # Retry history, narration only — a bundle carries no context + # snapshots, so the per-attempt script/world state it would + # restore isn't there to export either. + "variants": [ + {"text": v.get("text", ""), "reasoning": v.get("reasoning"), + "createdAt": v.get("created_at")} + for v in (a.variants or []) + ] or None, + "variantIndex": a.variant_index, "createdAt": a.created_at.isoformat(), } for a in adv.actions @@ -812,12 +1018,22 @@ def import_adventure( for i, a in enumerate(bundle.get("actions") or []): if isinstance(a, dict) and str(a.get("text") or ""): + variants = [ + {"text": str(v.get("text") or ""), "reasoning": v.get("reasoning"), + "created_at": v.get("createdAt")} + for v in (a.get("variants") or []) + if isinstance(v, dict) + ] db.add(models.Action( adventure_id=adventure.id, index=int(a.get("index", i)), type=str(a.get("type") or "story")[:20], # VARCHAR(20) text=str(a["text"]), reasoning=str(a["reasoning"]) if a.get("reasoning") else None, + variants=variants or None, + # Clamped: a bundle could name an index its variant list + # doesn't have, which would make the pager point at nothing. + variant_index=min(max(int(a.get("variantIndex", 0)), 0), max(len(variants) - 1, 0)), )) db.commit() @@ -1246,6 +1462,13 @@ def update_action( if action is None or action.adventure_id != adventure_id: raise HTTPException(404, "Action not found") action.text = payload.text + # Keep the live variant in step, or paging away and back would silently + # revert the edit. + variants = action.variants if isinstance(action.variants, list) else [] + if 0 <= action.variant_index < len(variants): + history = copy.deepcopy(variants) + history[action.variant_index]["text"] = payload.text + action.variants = history db.commit() return action diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 02e3009..656a001 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -183,9 +183,26 @@ class ActionOut(ORMModel): reasoning: str | None = None # Phase 12: compact RPG state changes for this turn (from the model property). world_changes: list[dict] = [] + # Retry history: how many attempts exist for this turn (0 = never retried) + # and which one is live. The attempts themselves come from + # GET /actions/{id}/variants so this payload stays small. + variant_count: int = 0 + variant_index: int = 0 created_at: datetime +class VariantOut(BaseModel): + index: int + text: str + reasoning: str | None = None + created_at: str | None = None + active: bool = False + + +class VariantSelect(BaseModel): + index: int = Field(ge=0) + + class ActionUpdate(BaseModel): text: ActionText diff --git a/backend/tests/test_retry_variants.py b/backend/tests/test_retry_variants.py new file mode 100644 index 0000000..032d14e --- /dev/null +++ b/backend/tests/test_retry_variants.py @@ -0,0 +1,323 @@ +"""End-to-end HTTP tests for retry history: every attempt at an AI turn is kept +as a variant of the same action, browsable and (for the last message) +switchable, restoring the world/script state that attempt produced. + + python -m pytest tests/test_retry_variants.py -v +""" +import os +import tempfile + +_tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) +_tmp.close() +os.environ["AIDND_DB_PATH"] = _tmp.name +os.environ.pop("AIDND_DATABASE_URL", None) +os.environ.pop("DATABASE_URL", None) + +import pytest +from fastapi import Depends +from fastapi.testclient import TestClient + +from app import auth, limits, models +from app.database import Base, SessionLocal, engine, get_db +from app.main import app +from app.providers import PromptParts, ProviderError +from app.routers import adventures + +SCHEMA = {"player": {"hp": {"min": 0, "max": 100, "initial": 100}}} + +# Each turn spends 10 gold, so a double-applied or un-rolled-back attempt shows. +GOLD_SCRIPT = """ +const modifier = (text) => { + state.gold = (state.gold || 0) + 10; + return { text }; +}; +modifier(text); +""" + + +class ScriptedProvider: + """Streams the next canned reply each call, so successive retries differ.""" + replies: list = [] + calls = 0 + + def __init__(self, *a, **k): + pass + + async def generate(self, parts: PromptParts, *, temperature, max_tokens): + index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1) + ScriptedProvider.calls += 1 + reply = ScriptedProvider.replies[index] + if isinstance(reply, Exception): + raise reply + yield ("text", reply) + + +@pytest.fixture() +def client(monkeypatch): + Base.metadata.create_all(bind=engine) + setup = SessionLocal() + user = models.User(is_guest=False, email="variants@example.com") + setup.add(user) + setup.flush() + setup.add(models.Settings(user_id=user.id, api_key="enc:dummy", model="test-model")) + scenario = models.Scenario(user_id=user.id, title="S", stat_schema=SCHEMA) + setup.add(scenario) + setup.flush() + adv = models.Adventure( + user_id=user.id, title="Cave", scenario_id=scenario.id, + script_state={}, world_state={"player": {"hp": 100}}, + ) + setup.add(adv) + setup.flush() + setup.add(models.Action(adventure_id=adv.id, index=0, type="start", text="You enter a cave.")) + setup.add(models.AdventureScript( + adventure_id=adv.id, position=0, enabled=True, name="Gold", output_js=GOLD_SCRIPT, + )) + setup.commit() + adv_id, user_id = adv.id, user.id + setup.close() + + ScriptedProvider.replies = ["Attempt one."] + ScriptedProvider.calls = 0 + monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider) + monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig( + "http://fake", "k", "test-model", False)) + monkeypatch.setattr(limits, "rate_limit", lambda *a, **k: None) + monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None) + + def _current_user(db=Depends(get_db)): + return db.get(models.User, user_id) + + app.dependency_overrides[auth.get_current_user] = _current_user + c = TestClient(app) + c.adv_id = adv_id + try: + yield c + finally: + app.dependency_overrides.clear() + adventures._active_turns.clear() + Base.metadata.drop_all(bind=engine) + + +def _adv(adv_id): + db = SessionLocal() + try: + adv = db.get(models.Adventure, adv_id) + return adv.script_state, adv.world_state + finally: + db.close() + + +def _actions(client): + return client.get(f"/api/adventures/{client.adv_id}").json()["actions"] + + +def _play(client, text="look around"): + r = client.post(f"/api/adventures/{client.adv_id}/actions", + json={"type": "do", "text": text}) + assert r.status_code == 200, r.text + + +def _retry(client): + r = client.post(f"/api/adventures/{client.adv_id}/retry") + assert r.status_code == 200, r.text + return r + + +# ---------------------------------------------------------------- keeping them + +def test_retry_keeps_the_discarded_attempt(client): + ScriptedProvider.replies = ["Attempt one.", "Attempt two."] + _play(client) + assert _actions(client)[-1]["text"] == "Attempt one." + + _retry(client) + actions = _actions(client) + # One AI action still, not two — the retry replaced the live text in place. + assert [a["type"] for a in actions] == ["start", "do", "ai"] + last = actions[-1] + assert last["text"] == "Attempt two." + assert last["variant_count"] == 2 + assert last["variant_index"] == 1 + + r = client.get(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants") + assert r.status_code == 200, r.text + assert [v["text"] for v in r.json()] == ["Attempt one.", "Attempt two."] + assert [v["active"] for v in r.json()] == [False, True] + + +def test_never_retried_action_has_no_variants(client): + _play(client) + last = _actions(client)[-1] + assert last["variant_count"] == 0 + assert client.get( + f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants").json() == [] + + +def test_three_attempts_all_kept_in_order(client): + ScriptedProvider.replies = ["One.", "Two.", "Three."] + _play(client) + _retry(client) + _retry(client) + last = _actions(client)[-1] + assert last["variant_count"] == 3 + assert last["variant_index"] == 2 + variants = client.get( + f"/api/adventures/{client.adv_id}/actions/{last['id']}/variants").json() + assert [v["text"] for v in variants] == ["One.", "Two.", "Three."] + + +# ---------------------------------------------------------------- switching + +def test_switching_back_restores_that_attempt_state(client): + ScriptedProvider.replies = [ + "You take a scratch.\n```state\n{\"player.hp\": -5}\n```", + "You take a beating.\n```state\n{\"player.hp\": -40}\n```", + ] + _play(client) + assert _adv(client.adv_id)[1]["player"]["hp"] == 95 + _retry(client) + script_state, world_state = _adv(client.adv_id) + assert world_state["player"]["hp"] == 60 + assert script_state == {"gold": 10} # rolled back, not stacked to 20 + + last = _actions(client)[-1] + r = client.post( + f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", json={"index": 0}) + assert r.status_code == 200, r.text + assert r.json()["text"].startswith("You take a scratch") + assert r.json()["variant_index"] == 0 + # The stats follow the narration back. + script_state, world_state = _adv(client.adv_id) + assert world_state["player"]["hp"] == 95 + assert script_state == {"gold": 10} + + # And forward again. + client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", + json={"index": 1}) + assert _adv(client.adv_id)[1]["player"]["hp"] == 60 + + +def test_switching_updates_the_world_change_chips(client): + ScriptedProvider.replies = [ + "A scratch.\n```state\n{\"player.hp\": -5}\n```", + "A beating.\n```state\n{\"player.hp\": -40}\n```", + ] + _play(client) + _retry(client) + last = _actions(client)[-1] + assert last["world_changes"][0]["delta"] == -40 + + client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", + json={"index": 0}) + assert _actions(client)[-1]["world_changes"][0]["delta"] == -5 + + +def test_cannot_switch_a_turn_the_story_moved_past(client): + ScriptedProvider.replies = ["One.", "Two.", "Three."] + _play(client) + _retry(client) + retried = _actions(client)[-1] + _play(client) # story continues from "Two." + + r = client.post( + f"/api/adventures/{client.adv_id}/actions/{retried['id']}/variant", json={"index": 0}) + assert r.status_code == 400 + assert "latest message" in r.json()["detail"] + # Still readable, though — that's the whole point of keeping them. + variants = client.get( + f"/api/adventures/{client.adv_id}/actions/{retried['id']}/variants").json() + assert [v["text"] for v in variants] == ["One.", "Two."] + + +def test_switching_to_a_missing_index_is_rejected(client): + ScriptedProvider.replies = ["One.", "Two."] + _play(client) + _retry(client) + last = _actions(client)[-1] + r = client.post( + f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", json={"index": 7}) + assert r.status_code == 400 + + +# ---------------------------------------------------------------- edge cases + +def test_failed_retry_leaves_the_previous_attempt_in_charge(client): + """A provider error mid-retry must undo the rollback, or the stats on + screen would silently disagree with the text still shown.""" + ScriptedProvider.replies = ["Attempt one.", ProviderError("upstream is down")] + _play(client) + assert _adv(client.adv_id)[0] == {"gold": 10} + + client.post(f"/api/adventures/{client.adv_id}/retry") + + actions = _actions(client) + assert actions[-1]["text"] == "Attempt one." # text never lost + assert _adv(client.adv_id)[0] == {"gold": 10} # and the state still matches it + + +def test_undo_removes_the_action_and_its_history(client): + ScriptedProvider.replies = ["One.", "Two."] + _play(client) + _retry(client) + r = client.post(f"/api/adventures/{client.adv_id}/undo") + assert r.status_code == 200, r.text + assert [a["type"] for a in r.json()] == ["start"] + assert _adv(client.adv_id)[0] == {} + + +def test_editing_the_text_updates_the_live_variant(client): + ScriptedProvider.replies = ["One.", "Two."] + _play(client) + _retry(client) + last = _actions(client)[-1] + client.patch(f"/api/adventures/{client.adv_id}/actions/{last['id']}", + json={"text": "Two, but better."}) + + # Page away and back: the edit must survive, not be reverted by the switch. + client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", + json={"index": 0}) + client.post(f"/api/adventures/{client.adv_id}/actions/{last['id']}/variant", + json={"index": 1}) + assert _actions(client)[-1]["text"] == "Two, but better." + + +def test_retry_keeps_the_turn_index(client): + """A retry re-runs the same turn, so the world-state clock (which drives + cooldowns) must not advance.""" + ScriptedProvider.replies = ["One.", "Two."] + _play(client) + before = _actions(client)[-1]["index"] + _retry(client) + assert _actions(client)[-1]["index"] == before + + +def test_export_and_import_round_trips_variants(client): + ScriptedProvider.replies = ["One.", "Two."] + _play(client) + _retry(client) + bundle = client.get(f"/api/adventures/{client.adv_id}/export").json() + ai = [a for a in bundle["actions"] if a["type"] == "ai"][0] + assert [v["text"] for v in ai["variants"]] == ["One.", "Two."] + assert ai["variantIndex"] == 1 + + r = client.post("/api/adventures/import", json=bundle) + assert r.status_code == 201, r.text + imported = r.json()["id"] + actions = client.get(f"/api/adventures/{imported}").json()["actions"] + assert actions[-1]["variant_count"] == 2 + assert actions[-1]["variant_index"] == 1 + + +def test_import_clamps_an_out_of_range_variant_index(client): + bundle = { + "format": "ai-dnd-adventure-v1", "title": "Hand-edited", + "actions": [{ + "index": 0, "type": "ai", "text": "Only.", + "variants": [{"text": "Only."}], "variantIndex": 9, + }], + } + r = client.post("/api/adventures/import", json=bundle) + assert r.status_code == 201, r.text + actions = client.get(f"/api/adventures/{r.json()['id']}").json()["actions"] + assert actions[0]["variant_index"] == 0 diff --git a/backend/tests/test_state_revert.py b/backend/tests/test_state_revert.py index 4e75552..026c02e 100644 --- a/backend/tests/test_state_revert.py +++ b/backend/tests/test_state_revert.py @@ -178,8 +178,8 @@ def test_snapshot_state_handles_non_dict(db): # ---------------------------------------------------------------- retry def test_retry_restores_state_before_regenerating(db, monkeypatch): - # Retry deletes the last AI action and must roll the scoreboard back to that - # action's snapshot so regeneration doesn't stack output mutations. + # Retry must roll the scoreboard back to the AI action's snapshot so + # regeneration doesn't stack output mutations on the discarded attempt. user, adv = _make_adventure(db, {"gold": 20}) # 20 = double-applied bug value _add(db, adv, 0, "start") _add(db, adv, 1, "do", state_before={"gold": 0}) @@ -197,5 +197,11 @@ def test_retry_restores_state_before_regenerating(db, monkeypatch): adventures.retry_action(adv.id, request=None, db=db, user=user) assert adv.script_state == {"gold": 10} - assert [a.type for a in adv.actions] == ["start", "do"] + # The row survives now (it used to be deleted) so the discarded attempt + # stays readable — it's kept as variant 0. + assert [a.type for a in adv.actions] == ["start", "do", "ai"] + last = adv.actions[-1] + assert len(last.variants) == 1 + assert last.variant_index == 0 + assert last.variants[0]["script_state"] == {"gold": 20} # the attempt's outcome adventures._active_turns.discard(adv.id) diff --git a/frontend/src/api.js b/frontend/src/api.js index 9e1a966..c2be995 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -90,6 +90,14 @@ export const api = { request(`/adventures/${advId}/actions/${actionId}`, { method: 'PATCH', body: JSON.stringify({ text }) }), deleteAction: (advId, actionId) => request(`/adventures/${advId}/actions/${actionId}`, { method: 'DELETE' }), + // Retry history. The adventure payload carries only the counts, so the + // attempts themselves are fetched when the reader actually pages through. + listVariants: (advId, actionId) => + request(`/adventures/${advId}/actions/${actionId}/variants`), + selectVariant: (advId, actionId, index) => + request(`/adventures/${advId}/actions/${actionId}/variant`, { + method: 'POST', body: JSON.stringify({ index }), + }), sendAction: (advId, payload, handlers, signal) => streamSSE(`/adventures/${advId}/actions`, payload, handlers, signal), diff --git a/frontend/src/index.css b/frontend/src/index.css index d726e60..edb642b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -392,6 +392,43 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n } .story .action-tools button:hover { color: var(--text); } +/* Retry-history pager under an AI beat. Stays quiet until hovered — it's a + footnote on the message, not part of the prose. */ +.variant-pager { + display: flex; + align-items: center; + gap: 4px; + margin-top: 6px; + font-family: var(--font-ui); + opacity: 0.45; + transition: opacity 0.15s; +} +.story .action:hover .variant-pager, +.variant-pager:focus-within { opacity: 1; } +.variant-pager button { + padding: 0 7px; + font-size: 0.9rem; + line-height: 1.5; + color: var(--text-dim); + background: transparent; + border: 1px solid transparent; +} +.variant-pager button:hover:not(:disabled) { color: var(--accent-bright); border-color: var(--border); } +.variant-pager button:disabled { opacity: 0.35; cursor: default; } +.variant-count { + font-size: 0.74rem; + color: var(--text-dim); + font-variant-numeric: tabular-nums; + min-width: 30px; + text-align: center; +} +.variant-note { + margin-left: 4px; + font-size: 0.72rem; + font-style: italic; + color: var(--accent-dim); +} + .action-edit { margin-bottom: 14px; } /* Sized by AutoTextarea to fit the text being edited — an AI beat is usually several paragraphs, and the old fixed 110px turned that into a keyhole. diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index 4a97608..b280b24 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -920,6 +920,59 @@ function WorldStateDrawer({ advId, refreshKey }) { ) } +// ChatGPT-style ‹ 2/3 › pager under an AI message that has been retried. +// +// The last message can actually be switched (the server restores the stats that +// attempt produced); earlier ones are read-only, because the turns after them +// were written as a continuation of whatever is active now. Browsing an +// earlier one is a local preview, so `onPreview` hands the text up to the story +// renderer rather than the pager drawing it. +function VariantPager({ advId, action, isLast, busy, previewIndex, onPreview, onSwitched, onError }) { + const [variants, setVariants] = useState(null) + const [loading, setLoading] = useState(false) + const count = action.variant_count + const current = previewIndex ?? action.variant_index + + async function go(delta) { + const next = current + delta + if (next < 0 || next >= count || loading || busy) return + setLoading(true) + try { + if (isLast) { + onPreview(null) + onSwitched(await api.selectVariant(advId, action.id, next)) + } else { + // Fetched once per message, then cached — paging back and forth + // shouldn't re-hit the server. + const list = variants || await api.listVariants(advId, action.id) + if (!variants) setVariants(list) + onPreview(next === action.variant_index + ? null + : { actionId: action.id, index: next, text: list[next].text, reasoning: list[next].reasoning }) + } + } catch (err) { + onError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+ + {current + 1}/{count} + + {previewIndex !== null && ( + + earlier attempt — the story continued from {action.variant_index + 1} + + )} +
+ ) +} + // Compact chips shown under an AI message summarizing what state changed. function StateChangeChips({ changes }) { if (!changes?.length) return null @@ -1118,6 +1171,9 @@ export default function Play() { // (currently "Update from scenario"), which no action count would reflect. const [stateKey, setStateKey] = useState(0) const [inspectActionId, setInspectActionId] = useState(null) + // Read-only browsing of an earlier attempt at a past turn (see VariantPager). + // One at a time; null when every message is showing its active version. + const [preview, setPreview] = useState(null) const storyEndRef = useRef(null) const abortRef = useRef(null) const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom @@ -1243,6 +1299,7 @@ export default function Play() { } function retry() { + setPreview(null) setActions((prev) => prev.length && prev[prev.length - 1].type === 'ai' ? prev.slice(0, -1) : prev) runTurn(async (signal) => { @@ -1259,6 +1316,7 @@ export default function Play() { async function undo() { setToast(null) + setPreview(null) try { setActions(await api.undo(id)) } catch (err) { @@ -1348,6 +1406,9 @@ export default function Play() { // Drop cap goes on the first narrated beat only. `firstNarrationId` // is derived once above rather than per row. const opening = action.id === firstNarrationId + // Non-null while the reader is browsing an older attempt of this + // message without making it active (earlier turns only). + const previewing = preview?.actionId === action.id ? preview : null return editing?.id === action.id ? (
@@ -1365,9 +1426,27 @@ export default function Play() { {sceneBreak && }
- - {renderEmphasis(action.text)} - {action.type === 'ai' && } + + {renderEmphasis(previewing ? previewing.text : action.text)} + {/* The chips describe the *active* attempt's state changes, + which a previewed one didn't make — so they're hidden + rather than shown against the wrong text. */} + {action.type === 'ai' && !previewing && ( + + )} + {action.type === 'ai' && action.variant_count > 1 && ( + + setActions((prev) => prev.map((a) => (a.id === updated.id ? updated : a)))} + onError={(message) => setToast({ text: message, isError: true })} + /> + )} {!busy && ( {action.type === 'ai' && (