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:
co-authored by
Claude Opus 5
parent
be6dbb0f24
commit
906ba423d8
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user