Skip to content

Edit and regenerate answer the edited message; restore files on rewind (#24) - #95

Merged
derekhearst merged 11 commits into
mainfrom
feat/24-rewind
Sep 24, 2026
Merged

derekhearst merged 11 commits into
mainfrom
feat/24-rewind

Conversation

@derekhearst

Copy link
Copy Markdown
Owner

Summary

Picked up the interrupted attempt (substantial uncommitted work), verified and hardened it, then finished tests, docs and commits. Edit and Regenerate no longer send the literal prompt 'regenerate' into the unedited SDK session. Every turn is now one streamed SDKUserMessage with a uuid we mint, recorded as the join on messages.metadata (sdkTurn on the user row, sdkTailUuid on the reply), so no migration was needed. A regenerate, including after an edit, sends the row's own text and attachments and resumes with resumeSessionAt at the previous reply's tail. That keeps the same session id and exactly the kept history. A cut the CLI refuses (for example one from before a compaction) is recognised before any model call and retried once on a fresh session primed with the kept history. File checkpointing is on for stable workspaces (a project or an agent persistentKey). A short-lived control session (resume, the run's cwd, enableFileCheckpointing, an input that never yields, no model call) runs rewindFiles dryRun for the preview and the real call on confirm. The restore dialog defaults on, lists the files with +/- totals, flags uncommitted files, needs an explicit overwrite for imported repos with uncommitted changes, and is hidden when there is nothing to restore. The server re-checks every guard, and a failed restore leaves the conversation unchanged. 'Compact Conversation' and the compaction before a model switch now send the SDK's /compact, and the switch notice is honest. The SDK behaviour was verified by running the bundled CLI against a stub Messages API: our uuid becomes the transcript uuid and the checkpoint key; the Write tool is checkpointed; resumeSessionAt drops the later turn and keeps the session id; /compact works as a plain-text prompt; a pre-compaction cut is refused with 'No message found'. The results are written up in docs/chat/chat.md.

After review: This round fixed all three should_fix items from the review. The branch still carries the earlier #24 work: file checkpointing, the rewind control session, and edit/regenerate cutting back the SDK session with resumeSessionAt. (1) Pending cut: editUserMessage and truncateAfterMessage now stamp the pivot row with metadata.sdkCutPending, in the same transaction as the cut. planTurn treats any turn in a conversation with a pending cut like a regenerate: it resumes at the previous reply's sdkTailUuid, keeps the same fresh fallback, and puts unanswered user rows after that reply in front of the text as a short preamble. Slash commands get no preamble, so /compact still works as a command. recordTurnJoin clears the stamp up to its row once the turn's session starts. So an edit whose stream POST fails, followed by a new message, no longer reaches the unedited session. (2) Finding 80 is now pinned. The /compact prompt and the model-switch notice moved into src/lib/chat/compact-command.ts, and the new tests/chat.compact.spec.ts pins the wording and a page-level click on Compact Conversation, asserting the POST content is exactly the /compact command with regenerate=false. (3) skippedLinks is passed through. applyMessageRewind reports filesRestored as the previewed files minus the skipped ones. BranchResult carries skippedLinks, and the page (one reportFileRestore line per handler) shows a dismissable notice rendered by RewindPreviewDialog, for example 'Restored 3 of 5 files. 2 were not restored: they are links...'. No migration was added. bun run check reports 0 errors and bun run build passes.

Closes #24

What was addressed

Audit findings (from the 2026-09-22 subsystem audit) were re-checked against the code before any change; "not a bug" means the claim did not hold up.

# Item Verdict Note
should_fix:turn-plan.server.ts:41 (pending cut after failed edit stream) should_fix:turn-plan.server.ts:41 (pending cut after failed edit stream) fixed Edit and regenerate stamp the pivot row with sdkCutPending in the cut transaction. planTurn cuts the next turn of any kind at the previous reply's tail, with an unanswered-rows preamble (none for slash commands) and the same fresh fallback. recordTurnJoin clears the stamp. Pinned in tests/chat.regenerate-fork.spec.ts: edit, then append U3 and plan a non-regenerate turn, then assert kind 'fork' with resumeSessionAt 'tail-1' and U2' in the preamble; after the join, the next message is 'continue'. Also pinned there: the regenerate stamp plus the /compact case, and clearing scoped to its own conve
should_fix:+page.svelte:1372 (finding 80 unpinned) should_fix:+page.svelte:1372 (finding 80 unpinned) fixed New src/lib/chat/compact-command.ts (compactCommand, compactSwitchNotice) is used by both the Compact Conversation button and the compaction before a model switch. New tests/chat.compact.spec.ts pins the prompt, the success and failure notice wording, and a real UI click whose POST body content equals '/compact …' with regenerate=false. The spec passes on desktop and mobile; on desktop it sets a 1024px viewport, because the button only exists in the phone/tablet header.
should_fix:message-branch.server.ts:77 (skippedLinks dropped) should_fix:message-branch.server.ts:77 (skippedLinks dropped) fixed ApplyRewindResult and BranchResult carry skippedLinks, and filesRestored is now the previewed count minus the skipped count (clamped). rewind-preview.ts restoreOutcomeNotice gives the wording. rewind-dialog.svelte.ts reportFileRestore sets state, and RewindPreviewDialog renders a dismissable top toast. Pinned by the regenerate-fork spec (a stub real rewind with skippedLinks: 2 gives filesRestored 1, skippedLinks 2) and the engine.rewind spec (wording).

Verification

  • bun run check: 0 errors
  • bun run build: passes
  • Specs: Run with E2E_PORT=4511 and the e2e-port patch, which was applied and then reverted. Suites: tests/chat.turn-plan.spec.ts, tests/chat.regenerate-fork.spec.ts, tests/engine.rewind.spec.ts, tests/engine.turn-input.spec.ts, tests/chat.edit-regenerate.spec.ts, tests/chat.compact.spec.ts and tests/auth.remote-guards.spec.ts, on both desktop and mobile. Final run: 176 passed. One earlier combined run had a single failure on mobile in chat.edit-regenerate: editMessage returned 'Message not found'. It did not happen again in 12/12 repeat-each runs or in the next full 176/176 run. The probable cause is outside this change: tests/global-setup.ts deletes every 'E2E:%' row, and other branches run Playwright in parallel against the same shared dev database.
  • Independent review: approve (0 must-fix, 3 should-fix), addressed in a follow-up round.

Docs updated

  • docs/chat/chat.md
  • docs/structure/parity-reaudit-2026-09-22.md
  • docs/chat/spec.md (earlier commit)
  • docs/context/spec.md (earlier commit)
  • docs/structure/claude-desktop-parity.md (earlier commit)
  • README.md (earlier commit)

Follow-ups / residual risk

  1. The pending-cut stamp is cleared in recordTurnJoin, which runs when the SDK sends its first message (onSessionId), as the reviewer suggested. If a turn is aborted after that point but before the CLI appends the user entry, the cut could be lost. The window is very small; a stale stamp does little harm, because the cut is taken at the latest reply before the turn. 2) The skipped-links toast has no UI-level spec. Driving it needs a real CLI restore with a symlink in the dev server's session store. The wording and the server pass-through are pinned in the unit and module specs. 3) Minor review observations not addressed, as they were non-blocking: while the preview runs, a composer send gets a 409 and shows the generic 'A turn is already running…' text (the server's own error text could be surfaced); a /compact turn saves an assistant row with '(no output)'; regenerating a /compact row whose cut is refused still puts the history preamble in front of the command on the fresh fallback (the fork attempt now skips it); the Compact button shows even when there is no SDK session, and /compact might error there (unverified); session-usage over-counting after a truncating resume (already on main); old conversations with no join fall back to a lossy text preamble (getSessionMessages() could recover the tail uuid). 4) There is no Compact Conversation entry point at desktop width; the context ring with the button is only in the phone/tablet header. 5) tests/global-setup.ts deletes every 'E2E:%' row. That makes UI specs flaky when several branches run Playwright against the shared dev DB at the same time; one such flake was seen here.

🤖 Generated with Claude Code

derekhearst and others added 11 commits September 23, 2026 20:12
Compact Conversation and the compaction before a switch to a smaller model sent an
ordinary message asking the model for a summary. The SDK session then carried that
summary on top of the full history it was meant to replace, so the context grew, and
the model-switch notice claimed a compaction that never happened.

Both now send `/compact` with the same keep-list. The CLI summarises and starts the
session again from the summary (checked against the bundled CLI: a compact_boundary
comes back and the next turn sends only the summary), and the turn shows its own
"Context compacted" notice. The switch notice says whether it worked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k SDK session

Edit and Regenerate sent the literal prompt "regenerate" into a resumed SDK session that
still held the original, unedited history. The edited text never reached the model, the
reply the user threw away stayed in its context, and memory recall, skill relevance, the
title and the evaluator all ran on the word "regenerate".

Every turn now goes in as one streamed SDKUserMessage carrying a uuid we mint, which the
CLI keeps as the transcript uuid (verified against the bundled CLI). The user row records
it with the session and the run's cwd (`metadata.sdkTurn`), and the reply records the last
main-thread chain entry its turn wrote (`metadata.sdkTailUuid`) — no migration.

A regenerate, after an edit too, sends the row's own text and attachments and resumes with
`resumeSessionAt` at the previous reply's tail: the same session id, exactly the kept
history. The first message, and replies from before the join or from another session,
start a fresh session primed with the kept rows as text; a cut the CLI refuses (one from
before a compaction) is recognised before any model call and retried once that way.
forkSession is not used — a fork starts without the file history.

File checkpointing is turned on for runs whose workspace outlives the turn (a project, or
an agent's persistent key), so the join can name a checkpoint; restoring comes next. The
edit button is hidden while a reply streams, and the page sends '' rather than a
placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Edit and Regenerate can now put back the files the dropped replies changed. Before
anything changes, the page asks the server for a preview: a short-lived control session
(`$lib/engine/rewind.server`) resumes the conversation's SDK session in the run's own cwd
with checkpointing on and an input that never yields, so no prompt is sent and no model is
called, and answers `rewindFiles(uuid, { dryRun: true })` from the file history it loads at
start-up. It works after a restart, which a live Query handle would not.

The dialog lists the files and line counts with "Also restore these files" ticked, flags
files git reports as uncommitted, and for an imported repository with uncommitted changes
in those files needs an explicit "overwrite them" before Continue works. It is not shown
when there is nothing to restore (no project, an older message, a turn that changed no
files), and says why when a checkpoint exists but cannot be used.

On confirm the server checks everything again — the caller's own message, a checkpoint
from the current session, a cwd inside their sandbox that still exists, no live turn and
no other rewind in the conversation, every file inside the workspace, the acknowledgement
— re-runs the dry run, restores, and only then edits or truncates the rows. A restore that
fails leaves the conversation as it was. Edit and truncate are refused while a turn runs,
and truncate now cuts by sequence rather than timestamp.

The real bundled CLI is exercised against a synthetic session on disk in
engine.rewind.spec.ts (no credentials); the rest is pinned through stubs, the database and
the page on both projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New docs/chat/chat.md covers what the model sees after an edit or regenerate, when files
are checkpointed, the restore dialog and its guards, compaction, and exactly which SDK
behaviours were verified against the bundled CLI and how. The chat and context specs,
README, the desktop parity table and the #24 section of the re-audit are updated; the
re-audit's two open questions for #24 are answered there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A real `rewindFiles` can refuse tracked files because a symlink, hard link or moved
parent directory is in the way (`RewindFilesResult.skippedLinks`); the dry run never
reports these. The count was logged and dropped, and `filesRestored` was the previewed
count, so a partial restore read as a full one.

`applyMessageRewind` now reports the previewed files less the skipped ones, the edit and
regenerate results carry `skippedLinks`, and the chat page shows a dismissable notice
("Restored 3 of 5 files. 2 were not restored: ...") when any were skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cut back to the kept history only happened on the regenerate request itself. If an
edit or regenerate truncated the rows and that request never reached the CLI (a dropped
connection, a failed setup, a restart), the next ordinary message resumed the full,
unedited session: the model saw the original message and the discarded reply while the
page showed the edited one.

Edit and regenerate now mark the row they cut back to (`metadata.sdkCutPending`) in the
same transaction as the cut. `planTurn` treats any turn in a conversation with a pending
cut like a regenerate: resume at the previous reply's `sdkTailUuid`, with the same fresh
fallback, and put the user rows after that reply (the ones the cut session never saw) in
front of the text as a short preamble. A slash command gets nothing in front, so `/compact`
still reads as a command. `recordTurnJoin` clears the mark up to its row once the turn's
session has started from the cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing held the finding 80 fix in place: a revert to the old "please summarise" prompt
passed the whole suite. The `/compact` prompt and the model-switch notice move out of the
chat page into `$lib/chat/compact-command`, and `tests/chat.compact.spec.ts` pins both: the
prompt starts with the command, the notice says whether compaction ran or failed, and on the
page Compact Conversation posts exactly that prompt as a new turn (not a regenerate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/chat/chat.md explains what happens when an edit's reply never starts (the next
message still cuts the session back, with unanswered messages carried as text), the
`sdkCutPending` key, slash commands sent while a cut is pending, and the notice after a
restore that left linked files alone. The #24 section of the re-audit notes both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #22 (`@` file mentions and the `/` palette in ChatComposer),
and with it the #14 right rail and #9 gateway that #22 had already
merged.

Conflicts:
- README.md: kept #24's edit/regenerate/restore/compact paragraph and
  #22's model-picker and composer-shortcut paragraphs, in that order;
  added docs/chat/chat.md and docs/llm/llm.md to the domain-doc list.
- docs/chat/spec.md: kept #24's "Editing, regenerating and restoring
  files" section and invariants beside #22's `@`/palette invariants.

Semantic fixes in the merge:
- The palette's `/compact` calls compactContext, which #24 switched to
  the CLI's own `/compact`. tests/chat.composer-mentions.spec.ts still
  expected the old "Please compact this conversation" prompt; it now
  expects compact-command's prompt.
- docs/chat/spec.md's `/compact` row, the chat-page-commands.ts comment
  and the #22 status in parity-reaudit said `/compact` asks for a
  summary / routing it to the SDK was still open; they now say it runs
  the SDK's `/compact`. docs/chat/chat.md names the Compact button's
  new home (the context ring in the chat header, #14) and `/compact`.

Checked, no change needed: stream/+server.ts refuses an unrunnable
model (#9) before planTurn (#24), so an edit's pending cut survives a
refused send; gateway turns carry sdkTailUuid and resumeSessionAt like
subscription turns; `/model` goes through maybeCompactBeforeModelSwitch
with #9's engine context limits and #24's `/compact`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Fallback

#24 wraps the route's `runEngineStream(` call in `runWithResumeFallback`,
so the source check for `await runEngineStream(` found nothing and
compared against -1. Look for the call itself, and assert both markers
exist so a missing one cannot pass as "before".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derekhearst
derekhearst merged commit 762beb7 into main Sep 24, 2026
1 check passed
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.

Filesystem checkpoints so a session can be rewound

1 participant