authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 00:10:19-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 00:10:19-03:00
logefccda9ac9230db22b36990cffabdad218fa41b0
tree42935712a0ec5743bc48a6073ecfeaf8aac1a822
parent25da986037a99d1e87bf1ed51aa58d7babee064a
signature Signed by PGP key B5690EEEBB952194

feat: rewind conversations while keeping file changes (#11358)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

10 files changed, 475 insertions(+), 161 deletions(-)

apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+120-74
......@@ -1683,86 +1683,132 @@ describe("CheckpointReactor", () => {
16831683 }),
16841684 );
16851685
1686 it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {
1687 const harness = await createHarness();
1688 const createdAt = "2026-01-01T00:00:00.000Z";
1686 it.each([
1687 { commandType: "thread.checkpoint.revert", initializeGit: true },
1688 { commandType: "thread.conversation.revert", initializeGit: true },
1689 { commandType: "thread.conversation.revert", initializeGit: false },
1690 ] as const)(
1691 "$commandType rewinds history with the requested filesystem behavior (git: $initializeGit)",
1692 async ({ commandType, initializeGit }) => {
1693 const harness = await createHarness({
1694 initializeGit,
1695 seedFilesystemCheckpoints: initializeGit,
1696 });
1697 const createdAt = "2026-01-01T00:00:00.000Z";
16891698
1690 await Effect.runPromise(
1691 harness.engine.dispatch({
1692 type: "thread.session.set",
1693 commandId: CommandId.make("cmd-session-set"),
1694 threadId: ThreadId.make("thread-1"),
1695 session: {
1699 await Effect.runPromise(
1700 harness.engine.dispatch({
1701 type: "thread.session.set",
1702 commandId: CommandId.make("cmd-session-set"),
16961703 threadId: ThreadId.make("thread-1"),
1697 status: "ready",
1698 providerName: "codex",
1699 runtimeMode: "approval-required",
1700 activeTurnId: null,
1701 lastError: null,
1702 updatedAt: createdAt,
1703 },
1704 createdAt,
1705 }),
1706 );
1704 session: {
1705 threadId: ThreadId.make("thread-1"),
1706 status: "ready",
1707 providerName: "codex",
1708 runtimeMode: "approval-required",
1709 activeTurnId: null,
1710 lastError: null,
1711 updatedAt: createdAt,
1712 },
1713 createdAt,
1714 }),
1715 );
17071716
1708 await Effect.runPromise(
1709 harness.engine.dispatch({
1710 type: "thread.turn.diff.complete",
1711 commandId: CommandId.make("cmd-diff-1"),
1712 threadId: ThreadId.make("thread-1"),
1713 turnId: asTurnId("turn-1"),
1714 completedAt: createdAt,
1715 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
1716 status: "ready",
1717 files: [],
1718 checkpointTurnCount: 1,
1719 createdAt,
1720 }),
1721 );
1722 await Effect.runPromise(
1723 harness.engine.dispatch({
1724 type: "thread.turn.diff.complete",
1725 commandId: CommandId.make("cmd-diff-2"),
1726 threadId: ThreadId.make("thread-1"),
1727 turnId: asTurnId("turn-2"),
1728 completedAt: createdAt,
1729 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
1730 status: "ready",
1731 files: [],
1732 checkpointTurnCount: 2,
1733 createdAt,
1734 }),
1735 );
1717 await Effect.runPromise(
1718 harness.engine.dispatch({
1719 type: "thread.turn.diff.complete",
1720 commandId: CommandId.make("cmd-diff-1"),
1721 threadId: ThreadId.make("thread-1"),
1722 turnId: asTurnId("turn-1"),
1723 completedAt: createdAt,
1724 checkpointRef: initializeGit
1725 ? checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)
1726 : CheckpointRef.make("provider-diff:thread-1:turn-1"),
1727 status: initializeGit ? "ready" : "missing",
1728 files: [],
1729 checkpointTurnCount: 1,
1730 createdAt,
1731 }),
1732 );
1733 await Effect.runPromise(
1734 harness.engine.dispatch({
1735 type: "thread.turn.diff.complete",
1736 commandId: CommandId.make("cmd-diff-2"),
1737 threadId: ThreadId.make("thread-1"),
1738 turnId: asTurnId("turn-2"),
1739 completedAt: createdAt,
1740 checkpointRef: initializeGit
1741 ? checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)
1742 : CheckpointRef.make("provider-diff:thread-1:turn-2"),
1743 status: initializeGit ? "ready" : "missing",
1744 files: [],
1745 checkpointTurnCount: 2,
1746 createdAt,
1747 }),
1748 );
17361749
1737 await Effect.runPromise(
1738 harness.engine.dispatch({
1739 type: "thread.checkpoint.revert",
1740 commandId: CommandId.make("cmd-revert-request"),
1741 threadId: ThreadId.make("thread-1"),
1742 turnCount: 1,
1743 createdAt,
1744 }),
1745 );
1750 NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "staged edit\n");
1751 if (initializeGit) {
1752 NodeChildProcess.execFileSync("git", ["add", "README.md"], { cwd: harness.cwd });
1753 }
1754 NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "unstaged edit\n");
1755 NodeFS.writeFileSync(NodePath.join(harness.cwd, "scratch.txt"), "untracked edit\n");
1756 const indexBefore = initializeGit
1757 ? NodeChildProcess.execFileSync("git", ["ls-files", "--stage"], {
1758 cwd: harness.cwd,
1759 encoding: "utf8",
1760 })
1761 : undefined;
17461762
1747 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
1748 const thread = await waitForThread(
1749 harness.readModel,
1750 (entry) => entry.checkpoints.length === 1,
1751 );
1763 await Effect.runPromise(
1764 harness.engine.dispatch({
1765 type: commandType,
1766 commandId: CommandId.make("cmd-revert-request"),
1767 threadId: ThreadId.make("thread-1"),
1768 turnCount: 1,
1769 createdAt,
1770 }),
1771 );
17521772
1753 expect(thread.latestTurn?.turnId).toBe("turn-1");
1754 expect(thread.checkpoints).toHaveLength(1);
1755 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
1756 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
1757 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
1758 threadId: ThreadId.make("thread-1"),
1759 numTurns: 1,
1760 });
1761 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");
1762 expect(
1763 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)),
1764 ).toBe(false);
1765 });
1773 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
1774 const thread = await waitForThread(
1775 harness.readModel,
1776 (entry) => entry.checkpoints.length === 1,
1777 );
1778
1779 expect(thread.latestTurn?.turnId).toBe("turn-1");
1780 expect(thread.checkpoints).toHaveLength(1);
1781 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
1782 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
1783 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
1784 threadId: ThreadId.make("thread-1"),
1785 numTurns: 1,
1786 });
1787 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe(
1788 commandType === "thread.conversation.revert" ? "unstaged edit\n" : "v2\n",
1789 );
1790 if (commandType === "thread.conversation.revert") {
1791 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "scratch.txt"), "utf8")).toBe(
1792 "untracked edit\n",
1793 );
1794 if (initializeGit) {
1795 expect(
1796 NodeChildProcess.execFileSync("git", ["ls-files", "--stage"], {
1797 cwd: harness.cwd,
1798 encoding: "utf8",
1799 }),
1800 ).toBe(indexBefore);
1801 }
1802 }
1803 if (initializeGit) {
1804 expect(
1805 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)),
1806 ).toBe(false);
1807 } else {
1808 expect(NodeFS.existsSync(NodePath.join(harness.cwd, ".git"))).toBe(false);
1809 }
1810 },
1811 );
17661812
17671813 it("executes provider revert and emits thread.reverted for claude sessions", async () => {
17681814 const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") });
apps/server/src/orchestration/Layers/CheckpointReactor.ts+51-44
......@@ -704,16 +704,11 @@ const make = Effect.gen(function* () {
704704 thread,
705705 projects: yield* resolveThreadProjects(thread.projectId),
706706 preferSessionRuntime: true,
707 });
708 if (!checkpointCwd) {
709 yield* appendRevertFailureActivity({
710 threadId: event.payload.threadId,
711 turnCount: event.payload.turnCount,
712 detail: "Checkpoint workspace is unavailable or is not a git repository.",
713 createdAt: now,
714 }).pipe(Effect.catch(() => Effect.void));
715 return;
716 }
707 }).pipe(
708 Effect.catch((error) =>
709 event.payload.restoreFiles === false ? Effect.succeed(undefined) : Effect.fail(error),
710 ),
711 );
717712
718713 const currentTurnCount = thread.checkpoints.reduce(
719714 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
......@@ -730,43 +725,55 @@ const make = Effect.gen(function* () {
730725 return;
731726 }
732727
733 const targetCheckpointRef =
734 event.payload.turnCount === 0
735 ? checkpointRefForThreadTurn(event.payload.threadId, 0)
736 : thread.checkpoints.find(
737 (checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount,
738 )?.checkpointRef;
728 yield* providerService.assertConversationRollbackSupported(event.payload.threadId);
739729
740 if (!targetCheckpointRef) {
741 yield* appendRevertFailureActivity({
742 threadId: event.payload.threadId,
743 turnCount: event.payload.turnCount,
744 detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`,
745 createdAt: now,
746 }).pipe(Effect.catch(() => Effect.void));
747 return;
748 }
730 if (event.payload.restoreFiles !== false) {
731 if (!checkpointCwd) {
732 yield* appendRevertFailureActivity({
733 threadId: event.payload.threadId,
734 turnCount: event.payload.turnCount,
735 detail: "Checkpoint workspace is unavailable or is not a git repository.",
736 createdAt: now,
737 }).pipe(Effect.catch(() => Effect.void));
738 return;
739 }
749740
750 yield* providerService.assertConversationRollbackSupported(event.payload.threadId);
741 const targetCheckpointRef =
742 event.payload.turnCount === 0
743 ? checkpointRefForThreadTurn(event.payload.threadId, 0)
744 : thread.checkpoints.find(
745 (checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount,
746 )?.checkpointRef;
747
748 if (!targetCheckpointRef) {
749 yield* appendRevertFailureActivity({
750 threadId: event.payload.threadId,
751 turnCount: event.payload.turnCount,
752 detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`,
753 createdAt: now,
754 }).pipe(Effect.catch(() => Effect.void));
755 return;
756 }
751757
752 const restored = yield* checkpointStore.restoreCheckpoint({
753 cwd: checkpointCwd,
754 checkpointRef: targetCheckpointRef,
755 fallbackToHead: event.payload.turnCount === 0,
756 });
757 if (!restored) {
758 yield* appendRevertFailureActivity({
759 threadId: event.payload.threadId,
760 turnCount: event.payload.turnCount,
761 detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`,
762 createdAt: now,
763 }).pipe(Effect.catch(() => Effect.void));
764 return;
765 }
758 const restored = yield* checkpointStore.restoreCheckpoint({
759 cwd: checkpointCwd,
760 checkpointRef: targetCheckpointRef,
761 fallbackToHead: event.payload.turnCount === 0,
762 });
763 if (!restored) {
764 yield* appendRevertFailureActivity({
765 threadId: event.payload.threadId,
766 turnCount: event.payload.turnCount,
767 detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`,
768 createdAt: now,
769 }).pipe(Effect.catch(() => Effect.void));
770 return;
771 }
766772
767 // Refresh the workspace entry index so the @-mention file picker
768 // reflects the reverted filesystem state.
769 yield* workspaceEntries.refresh(checkpointCwd);
773 // Refresh the workspace entry index so the @-mention file picker
774 // reflects the reverted filesystem state.
775 yield* workspaceEntries.refresh(checkpointCwd);
776 }
770777
771778 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
772779 if (rolledBackTurns > 0) {
......@@ -783,7 +790,7 @@ const make = Effect.gen(function* () {
783790 }
784791 }
785792
786 if (staleCheckpointRefs.length > 0) {
793 if (checkpointCwd && staleCheckpointRefs.length > 0) {
787794 yield* checkpointStore.deleteCheckpointRefs({
788795 cwd: checkpointCwd,
789796 checkpointRefs: staleCheckpointRefs,
apps/server/src/orchestration/decider.ts+2
......@@ -1640,6 +1640,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
16401640 };
16411641 }
16421642
1643 case "thread.conversation.revert":
16431644 case "thread.checkpoint.revert": {
16441645 yield* requireThread({
16451646 readModel,
......@@ -1657,6 +1658,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
16571658 payload: {
16581659 threadId: command.threadId,
16591660 turnCount: command.turnCount,
1661 ...(command.type === "thread.conversation.revert" ? { restoreFiles: false } : {}),
16601662 createdAt: command.createdAt,
16611663 },
16621664 };
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+127-20
......@@ -97,6 +97,8 @@ const runtimeMock = {
9797 promptEchoEvents: [] as Array<unknown>,
9898 closeError: null as Error | null,
9999 messages: [] as MessageEntry[],
100 forkMessagesBySession: new Map<string, MessageEntry[]>(),
101 forkPreservesBoundary: true,
100102 subscribedEvents: [] as Array<unknown | Promise<unknown>>,
101103 eventSubscribeObserved: null as (() => void) | null,
102104 eventStreamError: null as ((cause: unknown) => void) | null,
......@@ -128,7 +130,7 @@ const runtimeMock = {
128130 permissionListImplementation: null as (() => Promise<Array<PermissionRequest>>) | null,
129131 questionListImplementation: null as (() => Promise<Array<QuestionRequest>>) | null,
130132 sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>,
131 forkCalls: [] as Array<{ sessionID: string; directory?: string }>,
133 forkCalls: [] as Array<{ sessionID: string; directory?: string; messageID?: string }>,
132134 },
133135 reset() {
134136 this.state.startCalls.length = 0;
......@@ -157,6 +159,8 @@ const runtimeMock = {
157159 this.state.promptEchoEvents.length = 0;
158160 this.state.closeError = null;
159161 this.state.messages = [];
162 this.state.forkMessagesBySession.clear();
163 this.state.forkPreservesBoundary = true;
160164 this.state.subscribedEvents = [];
161165 this.state.eventSubscribeObserved = null;
162166 this.state.eventStreamError = null;
......@@ -264,7 +268,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
264268 return {
265269 data: {
266270 id: sessionID,
267 ...(runtimeMock.state.revertMessageID
271 ...(runtimeMock.state.revertMessageID &&
272 !runtimeMock.state.forkMessagesBySession.has(sessionID)
268273 ? { revert: { messageID: runtimeMock.state.revertMessageID } }
269274 : {}),
270275 ...(directory ? { directory } : {}),
......@@ -276,10 +281,37 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
276281 runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission });
277282 return { data: { id: sessionID } };
278283 },
279 fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => {
284 fork: async ({
285 sessionID,
286 directory,
287 messageID,
288 }: {
289 sessionID: string;
290 directory?: string;
291 messageID?: string;
292 }) => {
280293 // Fork clones history into a new session bound to the directory.
281294 const forkedId = `${sessionID}_fork`;
282 runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) });
295 runtimeMock.state.forkCalls.push({
296 sessionID,
297 ...(directory ? { directory } : {}),
298 ...(messageID ? { messageID } : {}),
299 });
300 if (messageID) {
301 const messages =
302 runtimeMock.state.forkMessagesBySession.get(sessionID) ?? runtimeMock.state.messages;
303 const boundary = messages.findIndex((entry) => entry.info.id === messageID);
304 NodeAssert.notEqual(boundary, -1);
305 runtimeMock.state.forkMessagesBySession.set(
306 forkedId,
307 messages
308 .slice(0, runtimeMock.state.forkPreservesBoundary ? boundary : messages.length)
309 .map((entry) => ({
310 ...entry,
311 info: { ...entry.info, id: `${entry.info.id}_fork` },
312 })),
313 );
314 }
283315 if (directory) {
284316 runtimeMock.state.sessionDirectoryById.set(forkedId, directory);
285317 }
......@@ -337,7 +369,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
337369 typeof input.sessionID === "string" &&
338370 typeof input.messageID === "string"
339371 ) {
340 runtimeMock.state.messages.push({
372 const messages =
373 runtimeMock.state.forkMessagesBySession.get(input.sessionID) ??
374 runtimeMock.state.messages;
375 messages.push({
341376 info: { id: input.messageID, role: "user" },
342377 parts: [],
343378 });
......@@ -355,14 +390,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
355390 runtimeMock.state.summarizeCalls.push(input);
356391 return { data: true };
357392 },
358 messages: async () => ({ data: runtimeMock.state.messages }),
393 messages: async ({ sessionID }: { sessionID: string }) => ({
394 data:
395 runtimeMock.state.forkMessagesBySession.get(sessionID) ?? runtimeMock.state.messages,
396 }),
359397 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
360398 runtimeMock.state.messageCalls.push({ sessionID, messageID });
361399 if (runtimeMock.state.messageFailures > 0) {
362400 runtimeMock.state.messageFailures -= 1;
363401 throw new Error("message lookup failed", { cause: { status: 500 } });
364402 }
365 const message = runtimeMock.state.messages.find((entry) => entry.info.id === messageID);
403 const messages =
404 runtimeMock.state.forkMessagesBySession.get(sessionID) ?? runtimeMock.state.messages;
405 const message = messages.find((entry) => entry.info.id === messageID);
366406 if (!message) {
367407 throw new Error(`Message not found: ${messageID}`, {
368408 cause: { status: 404, body: { name: "NotFoundError" } },
......@@ -6333,7 +6373,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
63336373 }).pipe(Effect.provide(adapterLayer));
63346374 });
63356375
6336 it.effect("reverts the first removed assistant message and returns only retained turns", () =>
6376 it.effect("forks before the removed user prompt and resumes only retained history", () =>
63376377 Effect.gen(function* () {
63386378 const adapter = yield* OpenCodeAdapter;
63396379 const threadId = asThreadId("thread-rollback-all");
......@@ -6356,49 +6396,116 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
63566396 },
63576397 ];
63586398
6399 const originalCursor = (yield* adapter.listSessions()).find(
6400 (session) => session.threadId === threadId,
6401 )?.resumeCursor;
6402 runtimeMock.state.forkPreservesBoundary = false;
6403 const boundaryError = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip);
6404 NodeAssert.match(boundaryError.message, /did not preserve the requested rewind boundary/);
6405 NodeAssert.deepEqual(
6406 (yield* adapter.listSessions()).find((session) => session.threadId === threadId)
6407 ?.resumeCursor,
6408 originalCursor,
6409 );
6410 runtimeMock.state.forkPreservesBoundary = true;
6411
63596412 for (const numTurns of [0, 1, 2, 3]) {
6360 runtimeMock.state.revertMessageID = undefined;
6361 runtimeMock.state.revertCalls.length = 0;
6413 yield* adapter.stopSession(threadId);
6414 yield* adapter.startSession({ threadId, runtimeMode: "full-access" });
6415 runtimeMock.state.forkCalls.length = 0;
63626416 const snapshot = yield* adapter.rollbackThread(threadId, numTurns);
63636417 NodeAssert.deepEqual(
6364 runtimeMock.state.revertCalls,
6418 runtimeMock.state.forkCalls.map(({ sessionID, messageID }) => ({ sessionID, messageID })),
63656419 numTurns === 0
63666420 ? []
63676421 : [
63686422 {
63696423 sessionID: "http://127.0.0.1:9999/session",
6370 messageID: numTurns === 1 ? "assistant-2" : "assistant-1",
6424 messageID: numTurns === 1 ? "user-2" : "user-1",
63716425 },
63726426 ],
63736427 );
63746428 NodeAssert.deepEqual(
63756429 snapshot.turns.map((turn) => turn.id),
6376 ["assistant-1", "assistant-2"].slice(0, Math.max(0, 2 - numTurns)),
6430 numTurns === 0
6431 ? ["assistant-1", "assistant-2"]
6432 : ["assistant-1_fork"].slice(0, Math.max(0, 2 - numTurns)),
63776433 );
6434 NodeAssert.deepEqual(runtimeMock.state.revertCalls, []);
63786435 }
6379 runtimeMock.state.revertMessageID = undefined;
6436 yield* adapter.stopSession(threadId);
6437 yield* adapter.startSession({ threadId, runtimeMode: "full-access" });
63806438 for (const remaining of [1, 0]) {
63816439 const snapshot = yield* adapter.rollbackThread(threadId, 1);
63826440 NodeAssert.equal(snapshot.turns.length, remaining);
63836441 NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns);
6442 const cursor = (yield* adapter.listSessions()).find(
6443 (session) => session.threadId === threadId,
6444 )?.resumeCursor;
6445 NodeAssert.deepEqual(cursor, {
6446 schemaVersion: 1,
6447 sessionId:
6448 remaining === 1
6449 ? "http://127.0.0.1:9999/session_fork"
6450 : "http://127.0.0.1:9999/session_fork_fork",
6451 });
6452 yield* adapter.stopSession(threadId);
6453 yield* adapter.startSession({ threadId, runtimeMode: "full-access", resumeCursor: cursor });
6454 NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns);
63846455 }
63856456 NodeAssert.deepEqual(
6386 runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID),
6387 ["assistant-2", "assistant-1"],
6457 runtimeMock.state.forkCalls.slice(-2).map((call) => call.messageID),
6458 ["user-2", "user-1_fork"],
63886459 );
6389 runtimeMock.state.revertMessageID = undefined;
6460 yield* adapter.sendTurn({
6461 threadId,
6462 input: "continue the retained conversation",
6463 modelSelection: createModelSelection(
6464 ProviderInstanceId.make("opencode"),
6465 "anthropic/claude-sonnet-4-5",
6466 ),
6467 });
6468 NodeAssert.equal(
6469 (runtimeMock.state.promptCalls.at(-1) as { sessionID: string }).sessionID,
6470 "http://127.0.0.1:9999/session_fork_fork",
6471 );
6472 const continuation = runtimeMock.state.promptCalls.at(-1) as {
6473 sessionID: string;
6474 messageID: string;
6475 };
6476 runtimeMock.state.forkMessagesBySession.get(continuation.sessionID)!.push({
6477 info: { id: "continuation-answer", role: "assistant" },
6478 parts: [{ id: "continuation-part", type: "text", text: "continued answer" }],
6479 });
6480 const continuationCursor = (yield* adapter.listSessions()).find(
6481 (session) => session.threadId === threadId,
6482 )?.resumeCursor;
6483 yield* adapter.stopSession(threadId);
6484 yield* adapter.startSession({
6485 threadId,
6486 runtimeMode: "full-access",
6487 resumeCursor: continuationCursor,
6488 });
6489 NodeAssert.deepEqual(
6490 (yield* adapter.readThread(threadId)).turns.map((turn) => turn.id),
6491 ["continuation-answer"],
6492 );
6493 NodeAssert.deepEqual((yield* adapter.rollbackThread(threadId, 1)).turns, []);
6494 NodeAssert.equal(runtimeMock.state.forkCalls.at(-1)?.messageID, continuation.messageID);
6495 yield* adapter.stopSession(threadId);
6496 yield* adapter.startSession({ threadId, runtimeMode: "full-access" });
63906497 runtimeMock.state.messages = runtimeMock.state.messages.filter(
63916498 (entry) => entry.info.id !== "user-2",
63926499 );
63936500 const sharedUserSnapshot = yield* adapter.rollbackThread(threadId, 1);
6394 NodeAssert.equal(runtimeMock.state.revertMessageID, "user-1");
6501 NodeAssert.equal(runtimeMock.state.forkCalls.at(-1)?.messageID, "user-1");
63956502 NodeAssert.deepEqual(sharedUserSnapshot.turns, []);
63966503 NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []);
63976504
63986505 runtimeMock.state.messages = [];
6399 runtimeMock.state.revertCalls.length = 0;
6506 runtimeMock.state.forkCalls.length = 0;
64006507 const emptySnapshot = yield* adapter.rollbackThread(threadId, 1);
6401 NodeAssert.deepEqual(runtimeMock.state.revertCalls, []);
6508 NodeAssert.deepEqual(runtimeMock.state.forkCalls, []);
64026509 NodeAssert.deepEqual(emptySnapshot.turns, []);
64036510 }),
64046511 );
apps/server/src/provider/Layers/OpenCodeAdapter.ts+81-6
......@@ -338,7 +338,7 @@ interface OpenCodeSessionContext {
338338 readonly client: OpencodeClient;
339339 readonly server: OpenCodeServerConnection;
340340 readonly directory: string;
341 readonly openCodeSessionId: string;
341 openCodeSessionId: string;
342342 readonly relatedSessionIds: Set<string>;
343343 readonly resolvedRequestIds: Set<string>;
344344 readonly autoRepliedRequestIds: Set<string>;
......@@ -3812,14 +3812,89 @@ export function makeOpenCodeAdapter(
38123812 const targetIndex = Math.max(0, snapshot.turns.length - numTurns);
38133813 const target = snapshot.turns[targetIndex];
38143814 if (target) {
3815 yield* runOpenCodeSdk("session.revert", () =>
3816 context.client.session.revert({
3815 const messages = yield* runOpenCodeSdk("session.messages", () =>
3816 context.client.session.messages({ sessionID: context.openCodeSessionId }),
3817 ).pipe(Effect.mapError(toRequestError));
3818 const entries = messages.data ?? [];
3819 const targetMessageIndex = entries.findIndex((entry) => entry.info.id === target.id);
3820 if (targetMessageIndex < 0) {
3821 return yield* toRequestError(
3822 new OpenCodeRuntimeError({
3823 operation: "session.fork",
3824 detail: "The OpenCode rewind boundary is no longer available.",
3825 }),
3826 );
3827 }
3828 const firstRemovedMessage =
3829 entries
3830 .slice(0, targetMessageIndex + 1)
3831 .findLast((entry) => entry.info.role === "user") ?? entries[targetMessageIndex]!;
3832 // Native revert also rewrites workspace files. Fork only the retained
3833 // conversation so T3 alone decides whether filesystem changes survive.
3834 const fork = yield* runOpenCodeSdk("session.fork", () =>
3835 context.client.session.fork({
38173836 sessionID: context.openCodeSessionId,
3818 messageID: target.id,
3837 messageID: firstRemovedMessage.info.id,
3838 directory: context.directory,
3839 }),
3840 ).pipe(Effect.mapError(toRequestError));
3841 if (!fork.data) {
3842 return yield* toRequestError(
3843 new OpenCodeRuntimeError({
3844 operation: "session.fork",
3845 detail: "OpenCode session.fork returned no session payload.",
3846 }),
3847 );
3848 }
3849 const forkedSessionId = fork.data.id;
3850 const forkMessages = yield* runOpenCodeSdk("session.messages", () =>
3851 context.client.session.messages({ sessionID: forkedSessionId }),
3852 ).pipe(Effect.mapError(toRequestError));
3853 if (forkMessages.data?.length !== entries.indexOf(firstRemovedMessage)) {
3854 return yield* toRequestError(
3855 new OpenCodeRuntimeError({
3856 operation: "session.fork",
3857 detail: "OpenCode did not preserve the requested rewind boundary.",
3858 }),
3859 );
3860 }
3861 yield* runOpenCodeSdk("session.update", () =>
3862 context.client.session.update({
3863 sessionID: forkedSessionId,
3864 permission: buildOpenCodePermissionRules(context.session.runtimeMode),
38193865 }),
38203866 ).pipe(Effect.mapError(toRequestError));
3821 // Native revert can move the boundary to the preceding user message.
3822 return yield* readThread(threadId);
3867 yield* clearPendingOpenCodeRequests(context, { type: "session.fork" });
3868 context.openCodeSessionId = forkedSessionId;
3869 context.relatedSessionIds.clear();
3870 context.relatedSessionIds.add(forkedSessionId);
3871 context.messageRoleById.clear();
3872 context.textPartsByMessageId.clear();
3873 context.turnTokenUsage = undefined;
3874 context.activeTurnId = undefined;
3875 context.interruptedTurnId = undefined;
3876 context.reconcileIdleStatus = false;
3877 context.awaitingBusyAfterInterruption = false;
3878 context.pendingIdleReconciliation = undefined;
3879 context.session = {
3880 ...context.session,
3881 resumeCursor: { schemaVersion: OPENCODE_RESUME_VERSION, sessionId: forkedSessionId },
3882 updatedAt: yield* nowIso,
3883 };
3884 yield* emit({
3885 ...(yield* buildEventBase({ threadId })),
3886 type: "thread.started",
3887 payload: { providerThreadId: forkedSessionId },
3888 });
3889 return {
3890 threadId,
3891 turns: forkMessages.data
3892 .filter((entry) => entry.info.role === "assistant")
3893 .map((entry) => ({
3894 id: TurnId.make(entry.info.id),
3895 items: [entry.info, ...entry.parts],
3896 })),
3897 };
38233898 }
38243899
38253900 return snapshot;
apps/web/src/components/ChatView.tsx+52-11
......@@ -6533,8 +6533,18 @@ export default function ChatView(props: ChatViewProps) {
65336533 return () => window.removeEventListener("paste", handler, true);
65346534 }, [activeThreadId, composerRef]);
65356535
6536 const [pendingRevert, setPendingRevert] = useState<{
6537 turnCount: number;
6538 messageId: MessageId;
6539 routeThreadKey: string;
6540 } | null>(null);
6541
6542 if (pendingRevert && pendingRevert.routeThreadKey !== routeThreadKey) {
6543 setPendingRevert(null);
6544 }
6545
65366546 const onRevertToTurnCount = useCallback(
6537 async (turnCount: number, messageId: MessageId) => {
6547 async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => {
65386548 const localApi = readLocalApi();
65396549 if (!localApi || !activeThread || isRevertingCheckpoint) return;
65406550 const message = activeThread.messages.find((message) => message.id === messageId);
......@@ -6558,15 +6568,8 @@ export default function ChatView(props: ChatViewProps) {
65586568 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
65596569 return;
65606570 }
6561 const confirmed = await localApi.dialogs.confirm(
6562 [
6563 "Edit from here?",
6564 "Rewind files and chat to before this message.",
6565 "Your prompt and attachments return to the composer.",
6566 ].join("\n"),
6567 { variant: "destructive" },
6568 );
6569 if (!confirmed) {
6571 if (restoreFiles === undefined) {
6572 setPendingRevert({ turnCount, messageId, routeThreadKey });
65706573 return;
65716574 }
65726575
......@@ -6599,7 +6602,7 @@ export default function ChatView(props: ChatViewProps) {
65996602 await waitForRevertedMessage(routeThreadRef, messageId, turnCount, async () => {
66006603 const result = await revertThreadCheckpoint({
66016604 environmentId,
6602 input: { threadId: activeThread.id, turnCount },
6605 input: { threadId: activeThread.id, turnCount, restoreFiles },
66036606 });
66046607 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
66056608 });
......@@ -9144,6 +9147,44 @@ export default function ChatView(props: ChatViewProps) {
91449147 </RightPanelSheet>
91459148 ) : null}
91469149
9150 <AlertDialog
9151 open={pendingRevert !== null && pendingRevert.routeThreadKey === routeThreadKey}
9152 onOpenChange={(open) => {
9153 if (!open) setPendingRevert(null);
9154 }}
9155 >
9156 <AlertDialogPopup>
9157 <AlertDialogHeader>
9158 <AlertDialogTitle>Edit from here?</AlertDialogTitle>
9159 <AlertDialogDescription>
9160 Rewind chat to before this message. Your prompt and attachments return to the
9161 composer.
9162 </AlertDialogDescription>
9163 </AlertDialogHeader>
9164 <AlertDialogFooter>
9165 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
9166 <Button
9167 variant="destructive"
9168 onClick={() => {
9169 if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
9170 setPendingRevert(null);
9171 void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true);
9172 }}
9173 >
9174 Revert files too
9175 </Button>
9176 <Button
9177 onClick={() => {
9178 if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
9179 setPendingRevert(null);
9180 void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, false);
9181 }}
9182 >
9183 Revert and keep changes
9184 </Button>
9185 </AlertDialogFooter>
9186 </AlertDialogPopup>
9187 </AlertDialog>
91479188 <LinkPullRequestDialogHost />
91489189 {expandedImage && (
91499190 <ExpandedImageDialog
docs/user/composer.md+4-3
......@@ -72,9 +72,10 @@ into a normal draft.
7272## Edit an earlier prompt
7373
7474On web and desktop, choose **Edit from here** beneath a sent message to rewind
75the conversation and workspace files to before that message. The selected prompt
76and its attachments return to the composer for editing and resending. Any unsent
77draft stays above the restored prompt.
75the conversation to before that message. Choose **Revert and keep changes** to
76leave workspace files as they are, or **Revert files too** to restore them as well.
77The selected prompt and its attachments return to the composer for editing and
78resending. Any unsent draft stays above the restored prompt.
7879
7980This removes the selected message and later conversation from the active thread
8081and provider history. It does not undo external actions or separate provider
packages/client-runtime/src/operations/commands.test.ts+22
......@@ -24,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2424import {
2525 archiveThread,
2626 createProject,
27 revertThreadCheckpoint,
2728 reorderActiveThread,
2829 settleThread,
2930 stopThreadSession,
......@@ -101,6 +102,27 @@ describe("environment commands", () => {
101102 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
102103 );
103104
105 it.effect("uses a distinct command when keeping workspace changes", () =>
106 Effect.gen(function* () {
107 const dispatched: ClientOrchestrationCommand[] = [];
108 const supervisor = yield* makeSupervisor(dispatched);
109 for (const restoreFiles of [undefined, true, false]) {
110 yield* revertThreadCheckpoint({
111 commandId: CommandId.make("rewind-command"),
112 threadId: ThreadId.make("thread-1"),
113 turnCount: 0,
114 ...(restoreFiles !== undefined ? { restoreFiles } : {}),
115 createdAt: "2026-06-06T00:01:00.000Z",
116 }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
117 }
118 expect(dispatched.map((command) => command.type)).toEqual([
119 "thread.checkpoint.revert",
120 "thread.checkpoint.revert",
121 "thread.conversation.revert",
122 ]);
123 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
124 );
125
104126 it.effect("preserves caller metadata for idempotent queued commands", () =>
105127 Effect.gen(function* () {
106128 const dispatched: ClientOrchestrationCommand[] = [];
packages/client-runtime/src/operations/commands.ts+6-3
......@@ -53,7 +53,9 @@ export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;
5353export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;
5454export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;
5555export type DismissThreadUserInputInput = CommandInput<"thread.user-input.dismiss">;
56export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">;
56export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert"> & {
57 readonly restoreFiles?: boolean;
58};
5759export type StopThreadSessionInput = CommandInput<"thread.session.stop">;
5860
5961type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand;
......@@ -355,9 +357,10 @@ export const dismissThreadUserInput: (input: DismissThreadUserInputInput) => Com
355357export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect =
356358 Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) {
357359 const metadata = yield* timestampedCommandMetadata(input);
360 const { restoreFiles, ...command } = input;
358361 return yield* dispatch({
359 ...input,
360 type: "thread.checkpoint.revert",
362 ...command,
363 type: restoreFiles === false ? "thread.conversation.revert" : "thread.checkpoint.revert",
361364 commandId: metadata.commandId,
362365 createdAt: metadata.createdAt,
363366 });
packages/contracts/src/orchestration.ts+10
......@@ -1287,6 +1287,13 @@ const ThreadCheckpointRevertCommand = Schema.Struct({
12871287 createdAt: IsoDateTime,
12881288});
12891289
1290// A separate command makes older servers reject history-only rewinds rather than
1291// ignoring an unfamiliar option and restoring files.
1292const ThreadConversationRevertCommand = Schema.Struct({
1293 ...ThreadCheckpointRevertCommand.fields,
1294 type: Schema.Literal("thread.conversation.revert"),
1295});
1296
12901297const ThreadSessionStopCommand = Schema.Struct({
12911298 type: Schema.Literal("thread.session.stop"),
12921299 commandId: CommandId,
......@@ -1327,6 +1334,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
13271334 ThreadUserInputRespondCommand,
13281335 ThreadUserInputDismissCommand,
13291336 ThreadCheckpointRevertCommand,
1337 ThreadConversationRevertCommand,
13301338 ThreadSessionStopCommand,
13311339]);
13321340export type DispatchableClientOrchestrationCommand =
......@@ -1359,6 +1367,7 @@ export const ClientOrchestrationCommand = Schema.Union([
13591367 ThreadUserInputRespondCommand,
13601368 ThreadUserInputDismissCommand,
13611369 ThreadCheckpointRevertCommand,
1370 ThreadConversationRevertCommand,
13621371 ThreadSessionStopCommand,
13631372]);
13641373export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type;
......@@ -1762,6 +1771,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({
17621771export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({
17631772 threadId: ThreadId,
17641773 turnCount: NonNegativeInt,
1774 restoreFiles: Schema.optional(Schema.Boolean),
17651775 createdAt: IsoDateTime,
17661776});
17671777