Skip to content

Rewind the conversation on /undo, staged so a redo can lift it (part 3 of #944) - #1345

Open
KazenDev wants to merge 3 commits into
CodebuffAI:mainfrom
KazenDev:feat/undo-conversation-rewind
Open

Rewind the conversation on /undo, staged so a redo can lift it (part 3 of #944)#1345
KazenDev wants to merge 3 commits into
CodebuffAI:mainfrom
KazenDev:feat/undo-conversation-rewind

Conversation

@KazenDev

Copy link
Copy Markdown

Stacked on #1344 (itself stacked on #1343). The four engine files and the picker in the diff below come from those two and are unchanged here between them; this PR's own change is 6 files, +466/-5.

/undo reverts files, but the model keeps the memory of the turn. So the agent looks at the disk, does not find the file it believes it just wrote, and writes it again — the undo undoes itself. Observed live in the CLI:

> create new.py with print("hola")
• Create new.py
Undid the last change:
🗑 new.py (deleted)
> (next turn) I see new.py is not in the directory listing. The earlier write
  may not have completed — let me create it again.
• Create new.py

This PR is the conversation half: an undo can now take the turn out of the model's memory as well as off the disk.

The two memories, and the one door

What Who reads it
Transcript useChatStore.messages the UI
Model memory runState.sessionState.mainAgentState.messageHistory the next run — and only the next run, through previousRunState

createRunConfig is called in exactly one place (cli/src/hooks/use-send-message.ts, the previousRunState argument), so cutting the model's history is one slice at one call site. The SDK never reads the stored output of a previous run (only sessionState, traceSessionId and inference), so nothing else needs to move.

The cut is staged, not applied

Nothing is deleted when you undo. The journal records a boundary and the history is slice-copied on the way into the run:

  • /undo puts the files back and sets rewind in the chat's undo.json{recordId, historyLength, transcriptIndex}.
  • The next run starts from messageHistory.slice(0, historyLength).
  • /redo deletes that mark, so the conversation comes back for free.
  • A turn that runs afterwards absorbs the cut: the state it persists was built from the cut history, and the mark is cleared then. An interrupted or failed run keeps it, because nothing absorbed it.

That is why a redo cannot lose a message here: there was never a copy to get wrong. It also matches what the tools everyone compares us to do — see the prior art below.

Each turn records where it started, in both memories, captured as its run begins (next to the stamp that already guards the entry against a newer turn): {historyLength, transcriptIndex}. An entry written before this existed has no anchor, and an undo of it still reverts its files but stages no cut — a guess would be worse than a file-only undo.

What each file does

File Change
cli/src/state/undo-rewind.ts (new) the pure helpers: the transcript anchor, the anchor reader, and the history cut (same reference when there is nothing to cut)
cli/src/state/undo-store.ts anchor on the record, RewindBoundary on the journal, getRewindBoundary/setRewindBoundary/clearRewindBoundary, undoToRecord(..., { conversation }) stages the cut, redoToRecord lifts it
cli/src/hooks/use-send-message.ts capture the anchor as the turn starts, pass it to recordUndoEntry, apply the staged cut at the run config, clear it once a run has absorbed it
cli/src/app.tsx the picker's selection asks for both halves
tests undo-rewind.test.ts (new, 10) and 6 more in undo-store.test.ts

Prior art (this is the shape the field settled on)

Not in this PR, on purpose

  • The picker still lists the reverted turns, and your prompt does not come back to the input. Hiding the tampered tail on screen and prefilling the composer for editing is the UI half, and the mode toggle that this default implies (code only vs code and conversation, both of which the tools above offer) goes with it.
  • The worktree is still the only thing with a snapshot. This PR is about the model's memory; the files half is Add the /undo snapshot engine, anchored so it survives cleanup (part 1 of #944) #1343's, unchanged.
  • Not secure erasure. The cut messages stay in the chat's saved transcript until you send the next prompt; the same is true of the tools above, and they say so too.
  • The cut is exact only as far as compaction allows. If a turn triggered mechanical compaction, the history that remains is the compacted one — coherent, but not byte-identical to what the turn saw. Declared rather than hidden.
  • creditsUsed is not rewound. The conversation goes back; the bill does not.

Verification

bun test cli/src, with the baseline taken by stashing exactly this PR's files (not from memory):

Files Tests Failures
Baseline (#1344) 203 3155 21
With this PR 204 3171 21 — the identical set (diff empty)

+16 tests = exactly the new ones. bun run typecheck in cli/: 10 errors, the exact baseline, 0 of them in these files.

Red before green, measured: with the staging switched off (false && …), 2 of the 6 journal tests fail — an undo takes the turn out of the model conversation too and a redo lifts the staged cut. The other 4 assert absence (no cut when it is not asked for, no cut without an anchor, a newer turn does not lift a staged cut, lifting twice is safe), so they pass either way — the pair is what pins the flag.

What these tests do not claim: the user-visible bug is a model behaviour (it prefers to rewrite a file it believes is missing), so no unit test reproduces it. The reproduction is the CLI transcript above; the tests pin the mechanism that removes the model's reason to rewrite.

How to try it

cd <a git project>
# one turn that edits, creates and deletes files
/undo            # Enter on a row
git diff         # the files are back
# then send a follow-up prompt: the agent no longer knows that turn happened
/redo            # the conversation half comes back too

Snapshot capture, restore, and the per-chat journal. No UI yet: the
picker, the command registry entry, and the send-message hook come in
the follow-up.

Three data-loss paths closed, each with a test that fails without it:
- a failed `git add` no longer hands back the tree of a stale index
- every entry is anchored (`refs/freebuff/undo/<chat>/<hash>`) so the
  cleanup job's prune cannot collect a snapshot the journal still lists
- reverting checks the content, not just the tree: `git checkout`
  deletes a file whose blob it cannot read, and `ls-tree` still lists it

The journal is the root set: refs are released when the entry is
dropped, and `sweepAnchors` collects what a deleted chat left behind.

Verified: 20 tests pass, the CLI typecheck has no new errors, and the
full suite matches its baseline (+20 pass, same 61 pre-existing failures).
…odebuffAI#944)

The UI half: the picker, the commands, and the send-path hook. The
snapshot engine (part 1) is CodebuffAI#1343 and is not touched here.

Two review points from CodebuffAI#944, each with a test that fails without its fix:

- The picker warned about the cascade only in a code comment. The files
  panel now says how many newer turns go with the selected one, and lists
  the union of the files they touch rather than the selected turn's alone.
- recordUndoEntry ran in the finally even when a newer turn had started
  (an interrupt releases the chain lock first), so its diff picked up that
  turn's files. A turn now stamps itself when it starts and records only
  while its stamp is still the newest for that chat.

Verified: 16 new tests, the CLI suite at 3069 pass with the same 74
pre-existing failures as the port alone, and a typecheck with no undo
errors.
…3 of CodebuffAI#944)

/undo reverted files while the model kept its memory of the turn, so the agent
would look at the disk, not find the file it believed it had just written, and
write it again - the undo undid itself.

A turn now records where it started in both memories, and an undo stages a cut at
that point: nothing is deleted, the model's history is slice-copied on its way
into createRunConfig (the one place a run is built), and /redo lifts the mark. A
turn that runs afterwards absorbs the cut and clears it.

The picker half - hiding the reverted tail on screen, prefilling the composer,
and choosing code-only vs code-and-conversation - is the next PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant