diff --git a/plugins/leaf/skills/leaf/scripts/leaf/events.py b/plugins/leaf/skills/leaf/scripts/leaf/events.py index 4ed03bed5..6789cf575 100644 --- a/plugins/leaf/skills/leaf/scripts/leaf/events.py +++ b/plugins/leaf/skills/leaf/scripts/leaf/events.py @@ -57,6 +57,15 @@ def undo_error(event: dict, events: list, within: dict) -> str | None: return f"unknown undoes {event['undoes']!r}" if target["author"] != "user": return f"{target['kind']} {target['id']} is not the reader's own gesture" + # Asked before the kind's own questions rather than after them, because a gesture + # already taken back is gone from the folds those questions read: `build_threads` + # drops a withdrawn reaction, so the thread walk below found none and raised + # StopIteration out of the append door. That is a 500, which the browser is told to + # retry against a state that will never change again — the outbox wedges on the + # gesture instead of putting it back. Withdrawal is a fact about the target and not + # about its kind, and it is the same no-op whichever kind carries it. + if target["id"] in taken_back(events): + return f"{target['id']} has already been taken back" if target["kind"] in MESSAGE_KINDS: if not is_reaction(target): return ( @@ -80,8 +89,6 @@ def undo_error(event: dict, events: list, within: dict) -> str | None: f"{target['kind']} events cannot be taken back (the kinds that can " f"are {', '.join(sorted(UNDOABLE_KINDS))} and a reaction)" ) - if target["id"] in taken_back(events): - return f"{target['id']} has already been taken back" return None diff --git a/tests/test_interact_contract.py b/tests/test_interact_contract.py index 7da0a82fc..b32ddaea2 100644 --- a/tests/test_interact_contract.py +++ b/tests/test_interact_contract.py @@ -3222,6 +3222,19 @@ def test_the_door_admits_a_reaction_only_as_a_token_the_layer_declares( data=json.dumps({"kind": "undo", "undoes": nod["id"]}).encode(), ) assert status == 200, body + # And comes off once, however the second press gets here — the racing tab of the + # door's own docstring. A withdrawn reaction is gone from `build_threads`, so the + # kind's thread walk had nothing to find and raised out of the door instead: a 500 + # the browser is told to retry, against a state that will never answer differently. + # The no-op costs a toast, which is what a final refusal is. + status, body = fetch( + f"{server}/api/event", + data=json.dumps({"kind": "undo", "undoes": nod["id"]}).encode(), + ) + assert status == 400, body + answer = json.loads(body) + assert answer["final"] is True, body + assert "already been taken back" in answer["error"], body # Answered, the page reaction is a conversation, and the withdrawal would orphan # the answer; the reader's move is in the thread it opened. conversation_model.cmd_reply(page_dir, reaction["id"], "Which part is long?", None)