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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
parththakkar106
2026-08-02 20:44:06 +05:30
co-authored by Claude Opus 5
parent be6dbb0f24
commit 906ba423d8
9 changed files with 734 additions and 22 deletions
+6
View File
@@ -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)
+13
View File
@@ -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"
+239 -16
View File
@@ -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
+17
View File
@@ -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
+323
View File
@@ -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
+9 -3
View File
@@ -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)
+8
View File
@@ -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),
+37
View File
@@ -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.
+82 -3
View File
@@ -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 (
<div className="variant-pager">
<button type="button" onClick={() => go(-1)} disabled={current === 0 || busy || loading}
title="Previous attempt">‹</button>
<span className="variant-count">{current + 1}/{count}</span>
<button type="button" onClick={() => go(1)} disabled={current === count - 1 || busy || loading}
title="Next attempt">›</button>
{previewIndex !== null && (
<span className="variant-note">
earlier attempt — the story continued from {action.variant_index + 1}
</span>
)}
</div>
)
}
// 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 ? (
<div key={action.id} className="action-edit">
@@ -1365,9 +1426,27 @@ export default function Play() {
<Fragment key={action.id}>
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
<div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
<ReasoningBlock text={action.reasoning} />
{renderEmphasis(action.text)}
{action.type === 'ai' && <StateChangeChips changes={action.world_changes} />}
<ReasoningBlock text={previewing ? previewing.reasoning : action.reasoning} />
{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 && (
<StateChangeChips changes={action.world_changes} />
)}
{action.type === 'ai' && action.variant_count > 1 && (
<VariantPager
advId={id}
action={action}
isLast={i === actions.length - 1}
busy={busy}
previewIndex={previewing ? previewing.index : null}
onPreview={setPreview}
onSwitched={(updated) =>
setActions((prev) => prev.map((a) => (a.id === updated.id ? updated : a)))}
onError={(message) => setToast({ text: message, isError: true })}
/>
)}
{!busy && (
<span className="action-tools">
{action.type === 'ai' && (