One Click on Regenerate, and 107 Messages Were Gone


The last reply was not quite right, so you click Regenerate. The UI refreshes, everything looks fine — until you scroll up and the conversation you had four hours ago has vanished, along with everything between then and now. You did not delete anything. You never clicked anything labelled clear.

This one was reported for real: a single regenerate turned a 430-message session into 323 messages. 107 live messages were silently archived, including everything after a turn from four hours earlier.

430 became 323

The bug lives in the question “which row is this regenerate rewinding to?”

In Desktop, regenerate and edit both work out a truncation plan first: the row to redo, and everything after it counted as history. The server validated that plan on submit, but it only rejected three cases:

  • stale anchors (error 4018);
  • ordinal drift (4030);
  • whole-transcript cuts (4028).

What slipped through all three: a valid row-id anchor that pointed at a very old row. And the client’s regenerate path trusted its own parentId without asking. Together, a regenerate clicked on the tail message could legitimately replay the turn at ordinal 31 and archive the hundred-odd messages after it.

Reproduced on main, a 430-message session was cut down to 310 at ordinal 31 with nothing in the way. Worth stating plainly: the client-side root cause was not reproduced, and no single culprit commit was identified. In other words, more than one path can trigger this class of misfire — which is why the fix went in at the exit rather than at any one entry.

Now it asks first

The approach is not “guess which turn you meant more cleverly”. It is show you the consequence before the action runs.

  • Regenerate (planConfirmedReload) and edit (planConfirmedEdit) now share one deep-cut check (confirmIfDeepCut);
  • Any rewind that would drop a later visible user turn opens the existing destructive confirm dialog (deepCutConfirmRequest, reusing the restore wording, so no new interface strings were added);
  • What stays uninterrupted is explicitly defined too: a tail regenerate, an edit of the last turn, and an edit of a failed turn do not prompt;
  • If you decline, nothing is sent. Restore keeps its own separate dialog.

A dialog can stay open a long time — you went for coffee — so changes during that window are handled:

  • Regenerate drops the plan if the session went busy or its transcript changed;
  • Edit re-aims at the current transcript with revalidateEditPlan, so a streaming reply that grows is accounted for; it stops with a notice if the edited turn moved or a new later user turn arrived;
  • Both drop the action if you switched sessions.

The server counts too

The client prompt is one layer. The server now actually counts what a cut would drop: _archived_user_turns measures the cut in durable user rows, and every detail leans conservative:

  • A repaired carrier counts every row of its merged user;user run, read from the physical rows;
  • Absorbed display markers do not count;
  • Unknown ids and an unstamped carrier’s own turn do count — fail closed rather than under-count;
  • On a durable-carrier cut, rows before the target survive.

If the client and the server count turns differently, the server answers 4033. When the client sees 4033:

  • It rolls the optimistic state back immediately, since the refused submit wrote nothing;
  • Then it asks you. If you accept, regenerate or edit re-runs from the top through the private runReload / runEdit, re-reading the session and re-checking it;
  • The forced deep cut covers only the transcript you answered for. Any change during it asks again.

Edit’s existing stale-target (4018) retry was tightened as well: it carries the deep confirm only when the refreshed transcript has the same number of later user turns.

This release only warns

One thing to be clear about: this version does not block deep cuts yet. It records them.

When the server sees an unconfirmed cut of more than one user turn, it logs ALLOWED (warn-only release) and still runs it. The enforcement switch is DEEP_TRUNCATE_ENFORCED; once the next release sets it, the same cut is refused with 4033 and the client takes the “roll back, ask, re-run” path above.

The sequencing is deliberate: one release of real-world data on how often this misfires, then the hard gate — rather than shipping enforcement immediately and blocking legitimate deep rewinds.

Status and upgrade

This fix (PR #133748, closing issue #133716, labelled P0) merged to main on 2026-10-11. The current tag, v0.21.6 from October 8, does not contain it; the next release will.

After upgrading you will notice two things: far fewer accidental cuts, and the occasional extra confirmation. That dialog is the entire point of the change — when it appears, it means you were about to lose conversation you had not read yet.

Wrapping up

Regenerate is the button people treat as “undo the last step”, when what it actually means is “redo from a certain row, and everything after is history”. Confusing the two lets a single stray click swallow a whole stretch of work. The new rule is blunt: if later conversation would be lost, ask first. The cost of protecting a session is one click; the cost of losing 107 messages is that you never find out what they said.

Related reading: session resume and continuation covers picking a conversation back up, the session boundaries guide explains what counts as a user boundary, and desktop embed isolation is about what happens inside the UI and what you are told about it.