Files
interactive-story/backend/tests/test_state_revert.py
T
JesseMarkowitzandClaude Opus 5 7f082b61d8 M3: complete non-destructive history and active-head export
The head-cursor model landed in 903fa7a and stopped there: the backend
moved the head instead of deleting turns, but a bundle still reopened at
its newest row, the browser had no way forward, and five inherited tests
still asserted the contract Undo had just stopped honouring. This is the
rest of the milestone, plus the one unsafe operation the review found.

Export now writes headDepth, and it belongs on the other side of the rule
app/bundle.py states about itself. The head depth used to be derived —
the tip of the head branch, a fact about the nodes that arrived with it —
and that was true while Undo deleted, because the newest row was the only
place a story could be read. It is a decision now: the same tree exports
identically whether the user undid three turns or none, so the file has
to say. An import that ignored it would silently Redo the story to its
newest retained turn, which is the Phase 0B export finding this milestone
exists to close. A file with no headDepth is opened at the tip, which is
not a fallback but the position such a file recorded; a file naming a
depth its own rows do not reach is refused in plan(), before a row is
written, for the reason that module gives about half-written trees.

Which branches the story has left goes into the file for the same reason.
Every row of an abandoned line arrives on an import either way, so the
disposition is the only thing telling it apart from an active one, and a
restored backup that had lost it would have nothing for the later cleanup
and recovery screens to select on. Both keys or neither: a time with no
depth cannot say what was displaced.

The browser gets a Redo button beside Undo, on Ctrl+Shift+Z, and both are
enabled from can_undo/can_redo rather than from the transcript. Neither
is derivable on the client — Undo stops at the campaign opening, which
may be off the top of the loaded window, and Redo depends on the retained
future, which the client is never sent — so the flags now ride on every
window the server hands back, including a scrolled-up page and the
response to an import. Moving the head also refreshes the state panels,
which undo never did: it has rolled the world state back since long
before M3 and the drawer kept showing the old numbers.

An in-place edit is now refused when story descends from the turn and is
not on screen. Editing rewrites one row and re-evaluates nothing, which
is what makes it a correction rather than a continuation, and that is
harmless while everything below the turn is visible — the reader can see
what their change has to agree with. It stops being harmless when the
continuation is undone, or was left behind by a divergence, because the
edit then silently changes the words an invisible stretch of story was
written from. That was the one way M3's retained history could be made to
contradict itself. Refusing is deliberately the whole of the fix: making
such an edit fork is STORY-BRANCH-SEMANTICS.md §14-15, and §15 wants the
state the edited prose implies re-evaluated, which is M5's extraction
pass. The requirement is not weakened, only deferred, and §14A now says
so.

The predicate asks one question rather than two. A descendant is
invisible either because it is past the head on this lineage or because
it is past a fork on a branch the story left, and both are "a live node,
deeper than this one, descending from it, off the path being read". A
first attempt scoped the search to branches other than the active one and
failed the divergence case, correctly: the departed branch is usually an
ancestor of the branch now being read. Only the deepest live node on each
descending branch is examined, because visibility is monotone in depth.

Five inherited tests are rewritten rather than deleted, because what they
were protecting is still worth protecting and only the mechanism changed.
The undo-state pair keeps its state assertions and swaps "the rows are
gone" for "the rows are all here and the story is read from earlier". The
memory test stops asserting that undo prunes memories and starts
asserting the property that replaced it: a memory past the head is
unreachable, still on disk, and retrievable again after Redo, with no
re-embedding. The attempt-group test still proves the group moves as one,
out of the story rather than out of the database. And the fork test
reverses: Undo used to refuse at a fork point because it deleted rows the
parent branch was also reading, and with nothing deleted there is nothing
to protect the parent from, so it now walks into the story the branch
inherits and stops at the campaign opening instead.

tests/test_head_cursor.py is the milestone's acceptance contract, named
by the items it discharges: D01-D10, E01-E04, I01-I03, I07, L01-L02, and
the invariant they all rest on — Undo deletes zero accepted turns,
asserted on row ids over the whole retained tree. E02 has both controls,
because a negative control alone would pass if memory retrieval were
simply broken. L01 records what it does not claim: the head does move by
one on a failed turn, onto the player's retained input, which is A05
rather than a gap. Two of the edit-guard tests exist to prove the guard
stays out of the way — a correction at the tip and a correction mid-story
with everything visible must both still work.

638 backend tests pass. Frontend lint is unchanged at seven pre-existing
warnings, none in the files touched; the bundle builds at 395.85 kB; the
production image builds.

Verified at runtime against the trusted-LAN Ollama over HTTPS: three
turns, two Undos, a Redo, a Retry, an Undo, a divergent continuation,
Redo correctly refused with 400, Undo back to the opening, export, a
process restart that reopened the campaign still undone, and an import
that opened at the same position with its retained future intact. The
browser click-through of that sequence has not been run — no session in
this milestone had a browser to drive — so the Redo control itself is
verified by its endpoint and its lint and build, not by a click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
2026-09-03 13:54:43 -04:00

339 lines
12 KiB
Python

"""Tests for undo and retry rolling back the shared `world_state`
(plan/11-state-revert-and-retry-fix.md).
The state being rolled back was the scripting engine's `script_state` until
M2 removed campaign scripting. The machinery under test — `attempts.restore_state`,
`roll_back_before`, and the per-node outcome snapshot — is unchanged; only the
column it moves has. `world_state`/`world_state_after` is now the shared state
an adventure carries, so that is what these tests exercise.
Phase 14 SP4 reversed the snapshots. An action used to carry the state as
it stood before it ran, and rolling back read the snapshot off the action
being removed. Now it carries the state it left behind, and rolling back
reads that state off the node in front of it. This is the same value
reached from the other direction, and it is the only version a retry can
use, because attempts at one turn share a starting position and differ
only in their outcome.
Run from the backend dir: python -m pytest tests/test_state_revert.py -v
"""
# Point the app at a throwaway SQLite file before importing anything that
# binds the engine at import time. `app.database` reads `AIDND_DB_PATH`
# on import.
import pytest
from fastapi import HTTPException
from app import attempts, memorybank, models
from app.database import Base, SessionLocal, engine
from app.routers import adventures
@pytest.fixture()
def db():
Base.metadata.create_all(bind=engine)
session = SessionLocal()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
adventures.turns._active_turns.clear()
def _make_adventure(db, world_state):
user = models.User(is_guest=False)
db.add(user)
db.flush()
adv = models.Adventure(user_id=user.id, title="T", world_state=world_state)
db.add(adv)
db.flush()
return user, adv
def _add(db, adv, index, type_, text="x", state_after=None):
a = models.Action(
adventure_id=adv.id, type=type_, text=text,
world_state_after=state_after,
)
db.add(a)
db.flush()
return a
def _row_count(db, adv):
"""Every action row the adventure holds, live or not, head or no head."""
return db.query(models.Action).filter_by(adventure_id=adv.id).count()
def _all_types(db, adv):
"""The retained story in depth order, which is not the same as the story
being told once the head has moved back behind the tip."""
rows = (
db.query(models.Action)
.filter_by(adventure_id=adv.id)
.order_by(models.Action.depth, models.Action.id)
.all()
)
return [a.type for a in rows]
def _retrievable(db, adv):
"""The memories the story can currently reach, read through the same clause
`memorybank` retrieves with — which is capped at the active head."""
from app.context import lineage
rows = (
db.query(models.Memory)
.filter(
models.Memory.adventure_id == adv.id,
lineage.path_of(db, adv).clause(models.Memory),
)
.all()
)
return {m.text for m in rows}
def _forget_snapshots(db, adv):
"""Blank every outcome, the way a row written before SP4 looks.
Straight SQL, because `tree.stamp_outcome` runs on every flush precisely so
that a node written through the ORM cannot end up without one.
"""
db.query(models.Action).filter_by(adventure_id=adv.id).update(
{"state_after": None, "world_state_after": None}, synchronize_session=False
)
db.commit()
db.expire_all()
# ---------------------------------------------------------------- undo
def test_undo_reverts_state_to_before_the_turn(db):
# A turn moved world_state from {gold:0} to {gold:10}. The node in
# front of the turn records where it started. The current state is
# the mutated one.
#
# M3 rewrote what the second half of this test asserts. Undo used to delete
# the turn, so the story was short afterwards because the rows were gone.
# It now moves the head, so the story is short because it is being read
# from somewhere earlier — and the rows are all still there. The state
# assertion is unchanged, because `attempts.restore_state` is unchanged:
# the state still comes off the node the story now ends on.
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 0})
_add(db, adv, 2, "ai", state_after={"gold": 10})
db.commit()
before = _row_count(db, adv)
page = adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.world_state == {"gold": 0}
# What the story now tells.
assert [a.type for a in page.actions] == ["start"]
assert adv.head_depth == 0
# What it still holds. Zero accepted turns deleted, which is the M3
# invariant this file is the closest test to.
assert _row_count(db, adv) == before
assert _all_types(db, adv) == ["start", "do", "ai"]
assert page.can_redo is True
def test_undo_of_bare_continue_uses_the_node_in_front(db):
# A "continue" turn has no player action, so the opening is what the story
# falls back to.
user, adv = _make_adventure(db, {"gold": 5})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "ai", state_after={"gold": 5})
db.commit()
page = adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.world_state == {"gold": 0}
assert [a.type for a in page.actions] == ["start"]
assert _all_types(db, adv) == ["start", "ai"]
def test_redo_puts_back_the_state_the_turn_left_behind(db):
"""The other half of the same mechanism: undo and redo restore the same
snapshot from opposite directions, because it belongs to the node rather
than to the direction of travel."""
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 0})
_add(db, adv, 2, "ai", state_after={"gold": 10})
db.commit()
adventures.undo_turn(adv.id, db=db, adventure=adv)
page = adventures.redo_turn(adv.id, db=db, adventure=adv)
assert adv.world_state == {"gold": 10}
assert [a.type for a in page.actions] == ["start", "do", "ai"]
assert adv.head_depth == 2
assert page.can_redo is False
def test_undo_leaves_state_untouched_when_snapshot_missing(db):
# A row the SP4 migration could not derive an outcome for: leave the live
# state alone rather than resetting it to nothing.
user, adv = _make_adventure(db, {"gold": 10})
_add(db, adv, 0, "start")
_add(db, adv, 1, "do")
_add(db, adv, 2, "ai")
_forget_snapshots(db, adv)
adventures.undo_turn(adv.id, db=db, adventure=adv)
assert adv.world_state == {"gold": 10}
def test_undo_raises_when_nothing_to_undo(db):
user, adv = _make_adventure(db, {})
_add(db, adv, 0, "start")
db.commit()
with pytest.raises(HTTPException) as exc:
adventures.undo_turn(adv.id, db=db, adventure=adv)
assert exc.value.status_code == 400
def test_undo_blocked_by_active_turn_lock(db):
user, adv = _make_adventure(db, {})
_add(db, adv, 0, "start")
_add(db, adv, 1, "ai", state_after={})
db.commit()
adventures.turns.acquire_turn_lock(adv.id) # a turn is "generating"
try:
with pytest.raises(HTTPException) as exc:
adventures.undo_turn(adv.id, db=db, adventure=adv)
assert exc.value.status_code == 409
# The failed undo must not have released someone else's lock.
assert adv.id in adventures.turns._active_turns
finally:
adventures.turns._active_turns.discard(adv.id)
def test_undo_stops_retrieving_a_memory_without_deleting_it(db):
"""M3 rewrote this test. Undo used to prune the memories covering the turns
it deleted, because those turns were gone and a summary of them described
story the adventure no longer had.
Nothing is deleted now, and nothing needs pruning either. A memory carries
the coordinate of the node its block ends on, so one derived from a turn
that is now past the head falls outside the head-capped path clause and
stops being retrievable — and becomes eligible again on Redo, without having
been deleted and re-embedded. That is `STORY-BRANCH-SEMANTICS.md` §33
holding as a consequence of the head rather than as its own mechanism.
"""
user, adv = _make_adventure(db, {})
for i in range(4):
_add(db, adv, i, "ai" if i % 2 else "do", state_after={})
db.commit()
# A memory ending on the turn undo will step behind, and one ending before
# it. The coordinate is what the clause reads; `source_*` only says which
# stretch the summarizer covered.
covering = models.Memory(
adventure_id=adv.id, text="m", source_start=0, source_end=3,
branch_id=adv.head_branch_id, depth=3,
)
keep = models.Memory(
adventure_id=adv.id, text="k", source_start=0, source_end=1,
branch_id=adv.head_branch_id, depth=1,
)
db.add_all([covering, keep])
db.commit()
adventures.undo_turn(adv.id, db=db, adventure=adv) # head moves to depth 1
assert _retrievable(db, adv) == {"k"}
# Still on disk, still embedded, still attached to the adventure.
assert {m.text for m in adv.memories} == {"k", "m"}
adventures.redo_turn(adv.id, db=db, adventure=adv)
assert _retrievable(db, adv) == {"k", "m"}
# -------------------------------------------------------- withdrawing a node
def test_forget_node_withdraws_only_what_that_node_produced(db):
"""Phase 14 SP3: a memory attaches to the node where its block ends, so
removing a node is a lookup rather than a scan for memories that
reference actions the story no longer has."""
user, adv = _make_adventure(db, {})
_add(db, adv, 0, "do")
second = _add(db, adv, 1, "ai")
db.add_all([
models.Memory(adventure_id=adv.id, text="hangs off node 1",
source_start=0, source_end=1),
models.Memory(adventure_id=adv.id, text="hangs off node 0",
source_start=0, source_end=0),
])
db.commit()
removed = memorybank.forget_node(db, adv, second)
db.commit()
db.refresh(adv) # expire_on_commit=False: reload the memories collection
assert removed == 1
assert {m.text for m in adv.memories} == {"hangs off node 0"}
# ---------------------------------------------------------------- snapshot
def test_snapshot_outcome_is_an_independent_deep_copy(db):
_, adv = _make_adventure(db, {"nested": {"n": 1}})
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
adv.world_state["nested"]["n"] = 99
assert node.world_state_after == {"nested": {"n": 1}} # unaffected by later mutation
def test_snapshot_outcome_handles_non_dict(db):
_, adv = _make_adventure(db, {})
adv.world_state = None
node = models.Action(adventure_id=adv.id, type="ai", text="x")
attempts.snapshot_outcome(adv, node)
assert node.world_state_after == {}
def test_restore_state_ignores_a_node_with_no_outcome(db):
_, adv = _make_adventure(db, {"gold": 7})
attempts.restore_state(adv, models.Action(adventure_id=adv.id, type="ai"))
assert adv.world_state == {"gold": 7}
attempts.restore_state(adv, None)
assert adv.world_state == {"gold": 7}
# ---------------------------------------------------------------- retry
def test_retry_restores_the_state_the_turn_started_from(db, monkeypatch):
# Retry must roll script_state back to what the node in front of the AI
# action left behind, so regeneration does not stack output mutations
# on top of the attempt being replaced.
user, adv = _make_adventure(db, {"gold": 20}) # 20 = double-applied bug value
_add(db, adv, 0, "start", state_after={"gold": 0})
_add(db, adv, 1, "do", state_after={"gold": 10})
_add(db, adv, 2, "ai", state_after={"gold": 20})
db.commit()
async def _noop(*a, **k):
if False:
yield # make it an async generator
monkeypatch.setattr(adventures.turns, "generate_turn", _noop)
adventures.retry_action(adv.id, request=None, db=db, user=user, adventure=adv)
assert adv.world_state == {"gold": 10}
# Nothing is written until a replacement actually arrives: the attempt on
# screen is left exactly as it was, and stays the live one.
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
last = adv.actions[-1]
assert last.live is True
assert len(attempts.group(db, last)) == 1 # no sibling was filed
assert last.world_state_after == {"gold": 20} # its own outcome, untouched
adventures.turns._active_turns.discard(adv.id)