Skip to content

feat: expose file and conversation rewind over ACP - #966

Open
Kanishk2207 wants to merge 1 commit into
agentclientprotocol:mainfrom
Kanishk2207:add-rewind-function
Open

feat: expose file and conversation rewind over ACP#966
Kanishk2207 wants to merge 1 commit into
agentclientprotocol:mainfrom
Kanishk2207:add-rewind-function

Conversation

@Kanishk2207

@Kanishk2207 Kanishk2207 commented Aug 6, 2026

Copy link
Copy Markdown

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:

  • no ACP method reaches Query.rewindFiles;
  • enableFileCheckpointing is never set, so there would be nothing to restore from anyway;
  • 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.

What this adds

Two extension methods, named alongside the existing _session/steering:

_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

files conversation
API query.rewindFiles(uuid) on the live query resumeSessionAt, a query creation option
Anchored at the user message uuid the assistant message before that turn
Cost a method call tear down and rebuild the SDK query

Because the anchors differ, _session/rewind_points reports both ids per entry. resumeAtMessageId is null for 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 resumeSessionAt truncates 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 /rewind rather than to session/fork.

Design notes

  • messageId, not a raw uuid. This is what messageIdToUuid was recorded for. For a user turn the two are equal (messageIdForGrouping returns the uuid), so the table 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. The stale "NOT READ YET" note is updated.
  • Session.creationParams stores the request that built the session. The query rebuild needs it: sessionFingerprint covers 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.options and yield a session that looks identical but no longer behaves as asked.
  • Ordering matters. Files run first, because rewindFiles is a method on the query the conversation half replaces.
  • Failure cascades deliberately. A refused file rewind in "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.
  • Rewinds are refused while a turn is in flight.
  • enableFileCheckpointing defaults on but stays overridable, placed before the ...userProvidedOptions spread 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.
  • dryRun covers both halves: file diffstat plus the number of messages that would be dropped, applying neither.
  • Capability discovery. initialize advertises _meta.claudeCode.rewindSession: { modes: [...] }, so clients feature-detect rather than calling and catching "method not found". Named rewindSession rather than rewind so it cannot be confused with the _meta.claudeCode.rewind key 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.
  • The ACP session id is unchanged, so clients keep their handle.

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, 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.

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.md documents the window. The alternative, making it durable immediately via the SDK's forkSession(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, matching unstable_forkSession.

Docs

docs/rewind-extension.md follows docs/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_forkSession and keeps forkSession: 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.

#872 (fork-at-message) this PR (rewind)
session id new unchanged
original session untouched, keeps every turn truncated, dropped turns are gone
anchor assistant message id, client-supplied user message id; the agent derives the assistant anchor
files not addressed files / both modes
answers "let me try a different path from here" "that didn't happen"

Neither 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" while session/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 messageId to an SDK uuid and feed it to resumeSessionAt, and both claim the Session.messageIdToUuid "NOT READ YET" comment.

This PR has since adopted #872's resolveMessageUuid verbatim 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-tree against #872's head now reports six conflicts, all in src/acp-agent.ts (previously two), every one mechanical:

conflict why resolution
messageIdToUuid doc comment both reworded it keep either; both are accurate post-merge
_meta.claudeCode capability block both add a key (rewind vs rewindSession) keep both keys
resolveMessageUuid ×3 fragments both now define it delete one copy
new-methods region insertion points adjacent keep both

The creationOpts widening for resumeSessionAt is 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:

  • "Revert to a previous chat message and rewind the context window to that point"mode: "conversation".
  • "Modify chat messages using the existing chat and resuming the conversation from that point" — no new method needed; it composes from _session/rewind (mode: "conversation") followed by an ordinary session/prompt carrying 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 in docs/rewind-extension.md.

Deliberately not covered:

  • Fork from an earlier message into a selectable second session — that is feat: support rewinding to a message when forking a session #872, and duplicating it here would be worse than linking it.
  • Bookmarks on a message — the enumeration a bookmark UI needs is already _session/rewind_points (ids, text, index). Naming and persisting them is client state; the SDK's tagSession/renameSession operate 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-limitation label. That blocker is gone: enableFileCheckpointing plus Query.rewindFiles cover the file half and resumeSessionAt covers the conversation half, which is what this PR wires up.

Tests

36 cases in src/tests/rewind.test.ts plus 2 in create-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 build and npm run test:run all pass (730 passed, 20 skipped).

Verified end to end against a live session for every mode. For both:

before:  file "CHANGED\n", transcript has the edit turn
dryRun:  {"files":{"canRewind":true,"insertions":1,"deletions":1},
          "conversation":{"rewound":false,"messagesDropped":7}}
apply:   {"files":{"canRewind":true,"skippedLinks":0},
          "conversation":{"rewound":true,"messagesDropped":7}}
after:   file "ORIGINAL\n", edit turn gone from the transcript,
         session accepts new prompts on the same id

Motivation

Written to give agent-shell (Emacs) a /rewind, where it is a longstanding gap versus the CLI. Happy to adjust the method names, the messageId versus uuid choice, the default mode, or the checkpointing default to whatever you would prefer.

@Kanishk2207 Kanishk2207 changed the title feat: expose file rewind over ACP feat: expose file and conversation rewind over ACP Aug 6, 2026
@Kanishk2207
Kanishk2207 force-pushed the add-rewind-function branch 3 times, most recently from a609ca0 to a728856 Compare August 6, 2026 21:09
@Kanishk2207
Kanishk2207 force-pushed the add-rewind-function branch from a728856 to 7505486 Compare August 7, 2026 09:33
- 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
Kanishk2207 force-pushed the add-rewind-function branch from 7505486 to 6e447ad Compare August 7, 2026 09:38
@Kanishk2207

Kanishk2207 commented Aug 7, 2026

Copy link
Copy Markdown
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 (enableFileCheckpointing + Query.rewindFiles for the files, resumeSessionAt for the conversation), and this reads the Session.messageIdToUuid mapping the codebase pre-staged for it.

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.

/rewind

1 participant