feat: expose file and conversation rewind over ACP - #966
Open
Kanishk2207 wants to merge 1 commit into
Open
Conversation
Kanishk2207
force-pushed
the
add-rewind-function
branch
3 times, most recently
from
August 6, 2026 21:09
a609ca0 to
a728856
Compare
Kanishk2207
force-pushed
the
add-rewind-function
branch
from
August 7, 2026 09:33
a728856 to
7505486
Compare
- add `_session/rewind_points`, listing the user messages a session can
be rewound to
- add `_session/rewind`, restoring the files, the conversation, or both
- default `enableFileCheckpointing` on, so there is something to restore
from, while leaving clients able to turn it back off
- read `Session.messageIdToUuid`, which was recorded for exactly this
- document the extension in `docs/rewind-extension.md`, linked from the
README alongside the goal extension
The Claude Code CLI can rewind a session to an earlier user message,
offering a choice of restoring the files, the conversation, or both
(`/rewind`, Esc-Esc). ACP clients cannot offer any of it: no method
reaches `Query.rewindFiles`, `enableFileCheckpointing` is never set so
there would be nothing to restore from anyway, and `resumeSessionAt` is
never used so the conversation cannot be truncated.
`Session.messageIdToUuid` already exists for this and says so: "NOT READ
YET, recorded now so the mapping exists if/when we wire up fork/rewind".
This reads it, and updates that note.
```
_session/rewind_points {sessionId}
-> {points: [{messageId, resumeAtMessageId, text, index}]}
_session/rewind {sessionId, messageId, mode?, dryRun?}
-> {files?: RewindFilesResult,
conversation?: {rewound, messagesDropped, error?}}
```
`mode` is `files` (default), `conversation`, or `both`.
The two halves are **different mechanisms**, not one operation with a
flag:
- **files** call `query.rewindFiles(uuid)` on the live query, keyed on
the **user** message uuid
- **conversation** uses `resumeSessionAt`, a query **creation** option,
so the SDK query is torn down and rebuilt resuming the same session id
truncated at the **assistant** message before that turn
Verified against a live session that `resumeSessionAt` truncates rather
than branches: the dropped turns leave both the transcript file and the
model's context. That is what makes this the counterpart to the CLI's
`/rewind` rather than to `session/fork`.
Because the anchors differ, `_session/rewind_points` reports both ids per
entry. `resumeAtMessageId` is `null` for a session's first prompt, which
has nothing before it to resume at; a conversation rewind of that turn is
refused with an explanation rather than a bare failure.
For a user turn the two are equal (`messageIdForGrouping` returns the
uuid), so `messageIdToUuid` only matters when a client passes an
assistant `msg_...` id. The lookup falls through to the id as given,
covering sessions resumed in another process.
`_session/rewind_points` reads the transcript via `getSessionMessages`
rather than that in-memory table, so turns from before a resume are
listed too. It returns only top-level user messages, skipping subagent
turns, tool results and synthetic `<...>` envelopes.
The query rebuild needs the request that built the session.
`sessionFingerprint` covers only cwd and mcpServers, being a change
detector rather than a record, so without this the rebuild would silently
drop the client's `_meta.claudeCode.options` and produce a session that
looks identical but no longer behaves as asked.
- files run **before** the conversation half, since `rewindFiles` is a
method on the query that half replaces
- a refused file rewind in `both` mode **skips** the conversation half,
rather than leaving the agent with no memory of edits still on disk
- a rewind is refused outright **while a turn is in flight**
- `dryRun` covers both halves, previewing the file diffstat and the
number of messages that would be dropped without applying either
The ACP session id is unchanged, so clients keep their handle.
A conversation rewind is immediate for the agent but reaches the
transcript on disk only when the next turn is written. Between the two,
`getSessionMessages` still returns the dropped turns, so
`_session/rewind_points` and a `session/load` replay both still show
them, and tearing the session down to resume it reloads the untruncated
transcript and silently undoes the rewind.
Clients must therefore not re-render a rewind by restarting, which is the
obvious thing to reach for given ACP has no "history changed"
notification. The safe handling is to leave the rendered history in place
and say those turns are no longer in the agent's context. After the next
prompt the truncation is on disk and a later reload replays correctly.
`docs/rewind-extension.md` documents this window.
`enableFileCheckpointing` is placed before the `...userProvidedOptions`
spread rather than among the ACP-controlled overrides: snapshotting costs
disk and I/O on every edit, so a client that offers no rewind can turn it
back off through `_meta.claudeCode.options`.
- `npm run test:run` — 726 passed, 20 skipped
- `npm run build`
- `npm run check`
34 new cases: 32 in `rewind.test.ts` covering param and mode validation,
point extraction and filtering, anchor pairing (including subagent
assistant messages, which are not valid anchors), messageId-to-uuid
translation, the query rebuild preserving creation params, dry-run
previews, first-prompt and unknown-message refusals, files-before-
teardown ordering, the `both` refusal cascade, and in-flight and closed
sessions; 2 in `create-session-options.test.ts` for the checkpointing
default and its override. Existing Session fixtures updated for
`creationParams`.
Also exercised end to end against a live session in every mode.
`docs/rewind-extension.md` follows `docs/goal-extension.md`: capability,
wire shapes, semantics, the durability window, and what clients must do
afterwards.
Kanishk2207
force-pushed
the
add-rewind-function
branch
from
August 7, 2026 09:38
7505486 to
6e447ad
Compare
Author
|
@benbrandt @nikita-ashihmin — review request when you have a moment. What it does: Adds /rewind functionality. Issues: #71 and #460. On #71 you noted in 2025-09 that this was waiting on Claude Code SDK support — that's now available ( |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The Claude Code CLI can rewind a session to an earlier user message, offering a choice of restoring the files, the conversation, or both (
/rewind, Esc-Esc). ACP clients cannot offer any of it:Query.rewindFiles;enableFileCheckpointingis never set, so there would be nothing to restore from anyway;resumeSessionAtis never used, so the conversation cannot be truncated.Session.messageIdToUuidalready exists for this, and says so:What this adds
Two extension methods, named alongside the existing
_session/steering:modeis"files"(default),"conversation", or"both".The two halves are different mechanisms
query.rewindFiles(uuid)on the live queryresumeSessionAt, a query creation optionBecause the anchors differ,
_session/rewind_pointsreports both ids per entry.resumeAtMessageIdisnullfor a session's first prompt, which has nothing preceding it; a conversation rewind of that turn is refused with an explanation rather than a bare failure.I verified against a live session that
resumeSessionAttruncates rather than branches — the dropped turns leave both the transcript file and the model's context — which is what makes this the counterpart to the CLI's/rewindrather than tosession/fork.Design notes
messageId, not a raw uuid. This is whatmessageIdToUuidwas recorded for. For a user turn the two are equal (messageIdForGroupingreturns the uuid), so the table only matters when a client passes an assistantmsg_…id; the lookup falls through to the id as given, covering sessions resumed in another process. The stale "NOT READ YET" note is updated.Session.creationParamsstores the request that built the session. The query rebuild needs it:sessionFingerprintcovers only cwd and mcpServers, being a change detector rather than a record, so without this a rebuild would silently drop the client's_meta.claudeCode.optionsand yield a session that looks identical but no longer behaves as asked.rewindFilesis a method on the query the conversation half replaces."both"mode skips the conversation half. Half a rewind is worse than none: it would leave the agent with no memory of edits still sitting on disk.enableFileCheckpointingdefaults on but stays overridable, placed before the...userProvidedOptionsspread rather than among the ACP-controlled overrides. Snapshotting costs disk and I/O on every edit, so a client offering no rewind keeps the last word.dryRuncovers both halves: file diffstat plus the number of messages that would be dropped, applying neither.initializeadvertises_meta.claudeCode.rewindSession: { modes: [...] }, so clients feature-detect rather than calling and catching "method not found". NamedrewindSessionrather thanrewindso it cannot be confused with the_meta.claudeCode.rewindkey feat: support rewinding to a message when forking a session #872 proposes for fork-at-a-message; the two can be advertised side by side.Durability of a conversation rewind
Worth calling out, because the obvious client implementation is wrong. A conversation rewind is immediate for the agent but reaches the transcript on disk only when the next turn is written. In between,
getSessionMessagesstill returns the dropped turns, so_session/rewind_pointsand asession/loadreplay both still show them, and tearing the session down to resume it reloads the untruncated transcript and silently undoes the rewind.ACP has no "history changed" notification, so re-rendering by restarting is the natural thing to reach for, and it is exactly what destroys the rewind. The safe handling is to leave the rendered history in place and tell the user those turns are no longer in the agent's context. After the next prompt the truncation is on disk and a later reload replays correctly.
I hit this building the client and confirmed it end to end both ways.
docs/rewind-extension.mddocuments the window. The alternative, making it durable immediately via the SDK'sforkSession(upToMessageId), mints a new session id and starts the fork without file-history snapshots, so file rewind would stop working afterwards; that seemed the worse trade for an experimental extension, but I am happy to revisit.Both agent methods use the
unstable_prefix, matchingunstable_forkSession.Docs
docs/rewind-extension.mdfollowsdocs/goal-extension.md: capability, wire shapes, semantics, the durability window, and client responsibilities. Linked from the README feature list alongside the goal extension.Related work
This is rewind, not fork — no functional overlap with #872
#872 ("support rewinding to a message when forking a session") sounds adjacent, and shares plumbing, but does a different thing. Its entire diff sits inside
unstable_forkSessionand keepsforkSession: true, so it mints a new session id and leaves the original intact. What it changes is where a fork starts: previously the end of the conversation, now optionally an earlier assistant turn.This PR mutates the original session in place.
files/bothmodesNeither can do the other's job: #872 cannot drop the tail of a live session, and this PR cannot produce a branch you switch back to. This is the same line RFD agent-client-protocol#1321 draws, where
session/fork"copies a session's prefix into a new session" whilesession/rewind"mutates the original in place… They compose." The SDK reinforces it from the other side: it documents that forked sessions "start without undo history (file-history snapshots are not copied)", so a fork is not a route to file rewind.They do overlap in code, and I would rather flag that than have a reviewer discover it. Both translate a client-facing
messageIdto an SDK uuid and feed it toresumeSessionAt, and both claim theSession.messageIdToUuid"NOT READ YET" comment.This PR has since adopted #872's
resolveMessageUuidverbatim in place of its own weaker map lookup, which used to fall through to the raw id on a cache miss and let the SDK fail obscurely later. That is a straight correctness win, and it deliberately converges the two PRs on one helper.It also, counter-intuitively, increases the textual conflict, and I would rather report the measurement than the intention.
git merge-treeagainst #872's head now reports six conflicts, all insrc/acp-agent.ts(previously two), every one mechanical:messageIdToUuiddoc comment_meta.claudeCodecapability blockrewindvsrewindSession)resolveMessageUuid×3 fragmentsThe
creationOptswidening forresumeSessionAtis character-identical in both and merges cleanly. No test file collides. Whichever lands first, the other should delete its copy of the helper; I am happy to be second and do that rebase.Issues
Closes #460 (
/rewind).Addresses the core of #71, but deliberately not marked as closing it: two of its four asks are out of scope here (see below), so whether it stays open is a maintainer's call rather than a side effect of merging.
#71 asks for four things. This PR covers two of them:
mode: "conversation"._session/rewind(mode: "conversation") followed by an ordinarysession/promptcarrying the new text. Verified end to end: the prefix survives, the replaced prompt leaves both context and transcript, and the replacement lands in its position. Documented indocs/rewind-extension.md.Deliberately not covered:
_session/rewind_points(ids, text, index). Naming and persisting them is client state; the SDK'stagSession/renameSessionoperate on sessions, not messages.#71 was answered in 2025-09 with "we are currently waiting on Claude Code SDK support for this exact feature", and carries the
sdk-limitationlabel. That blocker is gone:enableFileCheckpointingplusQuery.rewindFilescover the file half andresumeSessionAtcovers the conversation half, which is what this PR wires up.Tests
36 cases in
src/tests/rewind.test.tsplus 2 increate-session-options.test.ts: param and mode validation, point extraction and filtering, anchor pairing (including subagent assistant messages, which are not valid anchors),messageId-to-uuid translation, the query rebuild preserving creation params, dry-run previews, first-prompt and unknown-message refusals, files-before-teardown ordering, the both-mode refusal cascade, in-flight and closed sessions, and the checkpointing default with its override.npm run check,npm run buildandnpm run test:runall pass (730 passed, 20 skipped).Verified end to end against a live session for every mode. For
both:Motivation
Written to give agent-shell (Emacs) a
/rewind, where it is a longstanding gap versus the CLI. Happy to adjust the method names, themessageIdversus uuid choice, the default mode, or the checkpointing default to whatever you would prefer.