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", () => {...@@ -1683,86 +1683,132 @@ describe("CheckpointReactor", () => {
1683 }),1683 }),
1684 );1684 );
16851685
1686 it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {1686 it.each([
1687 const harness = await createHarness();1687 { commandType: "thread.checkpoint.revert", initializeGit: true },
1688 const createdAt = "2026-01-01T00:00:00.000Z";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(1699 await Effect.runPromise(
1691 harness.engine.dispatch({1700 harness.engine.dispatch({
1692 type: "thread.session.set",1701 type: "thread.session.set",
1693 commandId: CommandId.make("cmd-session-set"),1702 commandId: CommandId.make("cmd-session-set"),
1694 threadId: ThreadId.make("thread-1"),
1695 session: {
1696 threadId: ThreadId.make("thread-1"),1703 threadId: ThreadId.make("thread-1"),
1697 status: "ready",1704 session: {
1698 providerName: "codex",1705 threadId: ThreadId.make("thread-1"),
1699 runtimeMode: "approval-required",1706 status: "ready",
1700 activeTurnId: null,1707 providerName: "codex",
1701 lastError: null,1708 runtimeMode: "approval-required",
1702 updatedAt: createdAt,1709 activeTurnId: null,
1703 },1710 lastError: null,
1704 createdAt,1711 updatedAt: createdAt,
1705 }),1712 },
1706 );1713 createdAt,
1714 }),
1715 );
17071716
1708 await Effect.runPromise(1717 await Effect.runPromise(
1709 harness.engine.dispatch({1718 harness.engine.dispatch({
1710 type: "thread.turn.diff.complete",1719 type: "thread.turn.diff.complete",
1711 commandId: CommandId.make("cmd-diff-1"),1720 commandId: CommandId.make("cmd-diff-1"),
1712 threadId: ThreadId.make("thread-1"),1721 threadId: ThreadId.make("thread-1"),
1713 turnId: asTurnId("turn-1"),1722 turnId: asTurnId("turn-1"),
1714 completedAt: createdAt,1723 completedAt: createdAt,
1715 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),1724 checkpointRef: initializeGit
1716 status: "ready",1725 ? checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)
1717 files: [],1726 : CheckpointRef.make("provider-diff:thread-1:turn-1"),
1718 checkpointTurnCount: 1,1727 status: initializeGit ? "ready" : "missing",
1719 createdAt,1728 files: [],
1720 }),1729 checkpointTurnCount: 1,
1721 );1730 createdAt,
1722 await Effect.runPromise(1731 }),
1723 harness.engine.dispatch({1732 );
1724 type: "thread.turn.diff.complete",1733 await Effect.runPromise(
1725 commandId: CommandId.make("cmd-diff-2"),1734 harness.engine.dispatch({
1726 threadId: ThreadId.make("thread-1"),1735 type: "thread.turn.diff.complete",
1727 turnId: asTurnId("turn-2"),1736 commandId: CommandId.make("cmd-diff-2"),
1728 completedAt: createdAt,1737 threadId: ThreadId.make("thread-1"),
1729 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),1738 turnId: asTurnId("turn-2"),
1730 status: "ready",1739 completedAt: createdAt,
1731 files: [],1740 checkpointRef: initializeGit
1732 checkpointTurnCount: 2,1741 ? checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)
1733 createdAt,1742 : CheckpointRef.make("provider-diff:thread-1:turn-2"),
1734 }),1743 status: initializeGit ? "ready" : "missing",
1735 );1744 files: [],
1745 checkpointTurnCount: 2,
1746 createdAt,
1747 }),
1748 );
17361749
1737 await Effect.runPromise(1750 NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "staged edit\n");
1738 harness.engine.dispatch({1751 if (initializeGit) {
1739 type: "thread.checkpoint.revert",1752 NodeChildProcess.execFileSync("git", ["add", "README.md"], { cwd: harness.cwd });
1740 commandId: CommandId.make("cmd-revert-request"),1753 }
1741 threadId: ThreadId.make("thread-1"),1754 NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "unstaged edit\n");
1742 turnCount: 1,1755 NodeFS.writeFileSync(NodePath.join(harness.cwd, "scratch.txt"), "untracked edit\n");
1743 createdAt,1756 const indexBefore = initializeGit
1744 }),1757 ? NodeChildProcess.execFileSync("git", ["ls-files", "--stage"], {
1745 );1758 cwd: harness.cwd,
1759 encoding: "utf8",
1760 })
1761 : undefined;
17461762
1747 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");1763 await Effect.runPromise(
1748 const thread = await waitForThread(1764 harness.engine.dispatch({
1749 harness.readModel,1765 type: commandType,
1750 (entry) => entry.checkpoints.length === 1,1766 commandId: CommandId.make("cmd-revert-request"),
1751 );1767 threadId: ThreadId.make("thread-1"),
1768 turnCount: 1,
1769 createdAt,
1770 }),
1771 );
17521772
1753 expect(thread.latestTurn?.turnId).toBe("turn-1");1773 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
1754 expect(thread.checkpoints).toHaveLength(1);1774 const thread = await waitForThread(
1755 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);1775 harness.readModel,
1756 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);1776 (entry) => entry.checkpoints.length === 1,
1757 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({1777 );
1758 threadId: ThreadId.make("thread-1"),1778
1759 numTurns: 1,1779 expect(thread.latestTurn?.turnId).toBe("turn-1");
1760 });1780 expect(thread.checkpoints).toHaveLength(1);
1761 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");1781 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
1762 expect(1782 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
1763 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)),1783 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
1764 ).toBe(false);1784 threadId: ThreadId.make("thread-1"),
1765 });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
1767 it("executes provider revert and emits thread.reverted for claude sessions", async () => {1813 it("executes provider revert and emits thread.reverted for claude sessions", async () => {
1768 const harness = await createHarness({ providerName: ProviderDriverKind.make("claudeAgent") });1814 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* () {...@@ -704,16 +704,11 @@ const make = Effect.gen(function* () {
704 thread,704 thread,
705 projects: yield* resolveThreadProjects(thread.projectId),705 projects: yield* resolveThreadProjects(thread.projectId),
706 preferSessionRuntime: true,706 preferSessionRuntime: true,
707 });707 }).pipe(
708 if (!checkpointCwd) {708 Effect.catch((error) =>
709 yield* appendRevertFailureActivity({709 event.payload.restoreFiles === false ? Effect.succeed(undefined) : Effect.fail(error),
710 threadId: event.payload.threadId,710 ),
711 turnCount: event.payload.turnCount,711 );
712 detail: "Checkpoint workspace is unavailable or is not a git repository.",
713 createdAt: now,
714 }).pipe(Effect.catch(() => Effect.void));
715 return;
716 }
717712
718 const currentTurnCount = thread.checkpoints.reduce(713 const currentTurnCount = thread.checkpoints.reduce(
719 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),714 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
...@@ -730,43 +725,55 @@ const make = Effect.gen(function* () {...@@ -730,43 +725,55 @@ const make = Effect.gen(function* () {
730 return;725 return;
731 }726 }
732727
733 const targetCheckpointRef =728 yield* providerService.assertConversationRollbackSupported(event.payload.threadId);
734 event.payload.turnCount === 0
735 ? checkpointRefForThreadTurn(event.payload.threadId, 0)
736 : thread.checkpoints.find(
737 (checkpoint) => checkpoint.checkpointTurnCount === event.payload.turnCount,
738 )?.checkpointRef;
739729
740 if (!targetCheckpointRef) {730 if (event.payload.restoreFiles !== false) {
741 yield* appendRevertFailureActivity({731 if (!checkpointCwd) {
742 threadId: event.payload.threadId,732 yield* appendRevertFailureActivity({
743 turnCount: event.payload.turnCount,733 threadId: event.payload.threadId,
744 detail: `Checkpoint ref for turn ${event.payload.turnCount} is unavailable in read model.`,734 turnCount: event.payload.turnCount,
745 createdAt: now,735 detail: "Checkpoint workspace is unavailable or is not a git repository.",
746 }).pipe(Effect.catch(() => Effect.void));736 createdAt: now,
747 return;737 }).pipe(Effect.catch(() => Effect.void));
748 }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({758 const restored = yield* checkpointStore.restoreCheckpoint({
753 cwd: checkpointCwd,759 cwd: checkpointCwd,
754 checkpointRef: targetCheckpointRef,760 checkpointRef: targetCheckpointRef,
755 fallbackToHead: event.payload.turnCount === 0,761 fallbackToHead: event.payload.turnCount === 0,
756 });762 });
757 if (!restored) {763 if (!restored) {
758 yield* appendRevertFailureActivity({764 yield* appendRevertFailureActivity({
759 threadId: event.payload.threadId,765 threadId: event.payload.threadId,
760 turnCount: event.payload.turnCount,766 turnCount: event.payload.turnCount,
761 detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`,767 detail: `Filesystem checkpoint is unavailable for turn ${event.payload.turnCount}.`,
762 createdAt: now,768 createdAt: now,
763 }).pipe(Effect.catch(() => Effect.void));769 }).pipe(Effect.catch(() => Effect.void));
764 return;770 return;
765 }771 }
766772
767 // Refresh the workspace entry index so the @-mention file picker773 // Refresh the workspace entry index so the @-mention file picker
768 // reflects the reverted filesystem state.774 // reflects the reverted filesystem state.
769 yield* workspaceEntries.refresh(checkpointCwd);775 yield* workspaceEntries.refresh(checkpointCwd);
776 }
770777
771 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);778 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
772 if (rolledBackTurns > 0) {779 if (rolledBackTurns > 0) {
...@@ -783,7 +790,7 @@ const make = Effect.gen(function* () {...@@ -783,7 +790,7 @@ const make = Effect.gen(function* () {
783 }790 }
784 }791 }
785792
786 if (staleCheckpointRefs.length > 0) {793 if (checkpointCwd && staleCheckpointRefs.length > 0) {
787 yield* checkpointStore.deleteCheckpointRefs({794 yield* checkpointStore.deleteCheckpointRefs({
788 cwd: checkpointCwd,795 cwd: checkpointCwd,
789 checkpointRefs: staleCheckpointRefs,796 checkpointRefs: staleCheckpointRefs,
apps/server/src/orchestration/decider.ts+2
...@@ -1640,6 +1640,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1640,6 +1640,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1640 };1640 };
1641 }1641 }
16421642
1643 case "thread.conversation.revert":
1643 case "thread.checkpoint.revert": {1644 case "thread.checkpoint.revert": {
1644 yield* requireThread({1645 yield* requireThread({
1645 readModel,1646 readModel,
...@@ -1657,6 +1658,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -1657,6 +1658,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
1657 payload: {1658 payload: {
1658 threadId: command.threadId,1659 threadId: command.threadId,
1659 turnCount: command.turnCount,1660 turnCount: command.turnCount,
1661 ...(command.type === "thread.conversation.revert" ? { restoreFiles: false } : {}),
1660 createdAt: command.createdAt,1662 createdAt: command.createdAt,
1661 },1663 },
1662 };1664 };
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+127-20
...@@ -97,6 +97,8 @@ const runtimeMock = {...@@ -97,6 +97,8 @@ const runtimeMock = {
97 promptEchoEvents: [] as Array<unknown>,97 promptEchoEvents: [] as Array<unknown>,
98 closeError: null as Error | null,98 closeError: null as Error | null,
99 messages: [] as MessageEntry[],99 messages: [] as MessageEntry[],
100 forkMessagesBySession: new Map<string, MessageEntry[]>(),
101 forkPreservesBoundary: true,
100 subscribedEvents: [] as Array<unknown | Promise<unknown>>,102 subscribedEvents: [] as Array<unknown | Promise<unknown>>,
101 eventSubscribeObserved: null as (() => void) | null,103 eventSubscribeObserved: null as (() => void) | null,
102 eventStreamError: null as ((cause: unknown) => void) | null,104 eventStreamError: null as ((cause: unknown) => void) | null,
...@@ -128,7 +130,7 @@ const runtimeMock = {...@@ -128,7 +130,7 @@ const runtimeMock = {
128 permissionListImplementation: null as (() => Promise<Array<PermissionRequest>>) | null,130 permissionListImplementation: null as (() => Promise<Array<PermissionRequest>>) | null,
129 questionListImplementation: null as (() => Promise<Array<QuestionRequest>>) | null,131 questionListImplementation: null as (() => Promise<Array<QuestionRequest>>) | null,
130 sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>,132 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 }>,
132 },134 },
133 reset() {135 reset() {
134 this.state.startCalls.length = 0;136 this.state.startCalls.length = 0;
...@@ -157,6 +159,8 @@ const runtimeMock = {...@@ -157,6 +159,8 @@ const runtimeMock = {
157 this.state.promptEchoEvents.length = 0;159 this.state.promptEchoEvents.length = 0;
158 this.state.closeError = null;160 this.state.closeError = null;
159 this.state.messages = [];161 this.state.messages = [];
162 this.state.forkMessagesBySession.clear();
163 this.state.forkPreservesBoundary = true;
160 this.state.subscribedEvents = [];164 this.state.subscribedEvents = [];
161 this.state.eventSubscribeObserved = null;165 this.state.eventSubscribeObserved = null;
162 this.state.eventStreamError = null;166 this.state.eventStreamError = null;
...@@ -264,7 +268,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -264,7 +268,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
264 return {268 return {
265 data: {269 data: {
266 id: sessionID,270 id: sessionID,
267 ...(runtimeMock.state.revertMessageID271 ...(runtimeMock.state.revertMessageID &&
272 !runtimeMock.state.forkMessagesBySession.has(sessionID)
268 ? { revert: { messageID: runtimeMock.state.revertMessageID } }273 ? { revert: { messageID: runtimeMock.state.revertMessageID } }
269 : {}),274 : {}),
270 ...(directory ? { directory } : {}),275 ...(directory ? { directory } : {}),
...@@ -276,10 +281,37 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -276,10 +281,37 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
276 runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission });281 runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission });
277 return { data: { id: sessionID } };282 return { data: { id: sessionID } };
278 },283 },
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 }) => {
280 // Fork clones history into a new session bound to the directory.293 // Fork clones history into a new session bound to the directory.
281 const forkedId = `${sessionID}_fork`;294 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 }
283 if (directory) {315 if (directory) {
284 runtimeMock.state.sessionDirectoryById.set(forkedId, directory);316 runtimeMock.state.sessionDirectoryById.set(forkedId, directory);
285 }317 }
...@@ -337,7 +369,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -337,7 +369,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
337 typeof input.sessionID === "string" &&369 typeof input.sessionID === "string" &&
338 typeof input.messageID === "string"370 typeof input.messageID === "string"
339 ) {371 ) {
340 runtimeMock.state.messages.push({372 const messages =
373 runtimeMock.state.forkMessagesBySession.get(input.sessionID) ??
374 runtimeMock.state.messages;
375 messages.push({
341 info: { id: input.messageID, role: "user" },376 info: { id: input.messageID, role: "user" },
342 parts: [],377 parts: [],
343 });378 });
...@@ -355,14 +390,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -355,14 +390,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
355 runtimeMock.state.summarizeCalls.push(input);390 runtimeMock.state.summarizeCalls.push(input);
356 return { data: true };391 return { data: true };
357 },392 },
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 }),
359 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {397 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
360 runtimeMock.state.messageCalls.push({ sessionID, messageID });398 runtimeMock.state.messageCalls.push({ sessionID, messageID });
361 if (runtimeMock.state.messageFailures > 0) {399 if (runtimeMock.state.messageFailures > 0) {
362 runtimeMock.state.messageFailures -= 1;400 runtimeMock.state.messageFailures -= 1;
363 throw new Error("message lookup failed", { cause: { status: 500 } });401 throw new Error("message lookup failed", { cause: { status: 500 } });
364 }402 }
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);
366 if (!message) {406 if (!message) {
367 throw new Error(`Message not found: ${messageID}`, {407 throw new Error(`Message not found: ${messageID}`, {
368 cause: { status: 404, body: { name: "NotFoundError" } },408 cause: { status: 404, body: { name: "NotFoundError" } },
...@@ -6333,7 +6373,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -6333,7 +6373,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
6333 }).pipe(Effect.provide(adapterLayer));6373 }).pipe(Effect.provide(adapterLayer));
6334 });6374 });
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", () =>
6337 Effect.gen(function* () {6377 Effect.gen(function* () {
6338 const adapter = yield* OpenCodeAdapter;6378 const adapter = yield* OpenCodeAdapter;
6339 const threadId = asThreadId("thread-rollback-all");6379 const threadId = asThreadId("thread-rollback-all");
...@@ -6356,49 +6396,116 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -6356,49 +6396,116 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
6356 },6396 },
6357 ];6397 ];
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
6359 for (const numTurns of [0, 1, 2, 3]) {6412 for (const numTurns of [0, 1, 2, 3]) {
6360 runtimeMock.state.revertMessageID = undefined;6413 yield* adapter.stopSession(threadId);
6361 runtimeMock.state.revertCalls.length = 0;6414 yield* adapter.startSession({ threadId, runtimeMode: "full-access" });
6415 runtimeMock.state.forkCalls.length = 0;
6362 const snapshot = yield* adapter.rollbackThread(threadId, numTurns);6416 const snapshot = yield* adapter.rollbackThread(threadId, numTurns);
6363 NodeAssert.deepEqual(6417 NodeAssert.deepEqual(
6364 runtimeMock.state.revertCalls,6418 runtimeMock.state.forkCalls.map(({ sessionID, messageID }) => ({ sessionID, messageID })),
6365 numTurns === 06419 numTurns === 0
6366 ? []6420 ? []
6367 : [6421 : [
6368 {6422 {
6369 sessionID: "http://127.0.0.1:9999/session",6423 sessionID: "http://127.0.0.1:9999/session",
6370 messageID: numTurns === 1 ? "assistant-2" : "assistant-1",6424 messageID: numTurns === 1 ? "user-2" : "user-1",
6371 },6425 },
6372 ],6426 ],
6373 );6427 );
6374 NodeAssert.deepEqual(6428 NodeAssert.deepEqual(
6375 snapshot.turns.map((turn) => turn.id),6429 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)),
6377 );6433 );
6434 NodeAssert.deepEqual(runtimeMock.state.revertCalls, []);
6378 }6435 }
6379 runtimeMock.state.revertMessageID = undefined;6436 yield* adapter.stopSession(threadId);
6437 yield* adapter.startSession({ threadId, runtimeMode: "full-access" });
6380 for (const remaining of [1, 0]) {6438 for (const remaining of [1, 0]) {
6381 const snapshot = yield* adapter.rollbackThread(threadId, 1);6439 const snapshot = yield* adapter.rollbackThread(threadId, 1);
6382 NodeAssert.equal(snapshot.turns.length, remaining);6440 NodeAssert.equal(snapshot.turns.length, remaining);
6383 NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns);6441 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);
6384 }6455 }
6385 NodeAssert.deepEqual(6456 NodeAssert.deepEqual(
6386 runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID),6457 runtimeMock.state.forkCalls.slice(-2).map((call) => call.messageID),
6387 ["assistant-2", "assistant-1"],6458 ["user-2", "user-1_fork"],
6388 );6459 );
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" });
6390 runtimeMock.state.messages = runtimeMock.state.messages.filter(6497 runtimeMock.state.messages = runtimeMock.state.messages.filter(
6391 (entry) => entry.info.id !== "user-2",6498 (entry) => entry.info.id !== "user-2",
6392 );6499 );
6393 const sharedUserSnapshot = yield* adapter.rollbackThread(threadId, 1);6500 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");
6395 NodeAssert.deepEqual(sharedUserSnapshot.turns, []);6502 NodeAssert.deepEqual(sharedUserSnapshot.turns, []);
6396 NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []);6503 NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []);
63976504
6398 runtimeMock.state.messages = [];6505 runtimeMock.state.messages = [];
6399 runtimeMock.state.revertCalls.length = 0;6506 runtimeMock.state.forkCalls.length = 0;
6400 const emptySnapshot = yield* adapter.rollbackThread(threadId, 1);6507 const emptySnapshot = yield* adapter.rollbackThread(threadId, 1);
6401 NodeAssert.deepEqual(runtimeMock.state.revertCalls, []);6508 NodeAssert.deepEqual(runtimeMock.state.forkCalls, []);
6402 NodeAssert.deepEqual(emptySnapshot.turns, []);6509 NodeAssert.deepEqual(emptySnapshot.turns, []);
6403 }),6510 }),
6404 );6511 );
apps/server/src/provider/Layers/OpenCodeAdapter.ts+81-6
...@@ -338,7 +338,7 @@ interface OpenCodeSessionContext {...@@ -338,7 +338,7 @@ interface OpenCodeSessionContext {
338 readonly client: OpencodeClient;338 readonly client: OpencodeClient;
339 readonly server: OpenCodeServerConnection;339 readonly server: OpenCodeServerConnection;
340 readonly directory: string;340 readonly directory: string;
341 readonly openCodeSessionId: string;341 openCodeSessionId: string;
342 readonly relatedSessionIds: Set<string>;342 readonly relatedSessionIds: Set<string>;
343 readonly resolvedRequestIds: Set<string>;343 readonly resolvedRequestIds: Set<string>;
344 readonly autoRepliedRequestIds: Set<string>;344 readonly autoRepliedRequestIds: Set<string>;
...@@ -3812,14 +3812,89 @@ export function makeOpenCodeAdapter(...@@ -3812,14 +3812,89 @@ export function makeOpenCodeAdapter(
3812 const targetIndex = Math.max(0, snapshot.turns.length - numTurns);3812 const targetIndex = Math.max(0, snapshot.turns.length - numTurns);
3813 const target = snapshot.turns[targetIndex];3813 const target = snapshot.turns[targetIndex];
3814 if (target) {3814 if (target) {
3815 yield* runOpenCodeSdk("session.revert", () =>3815 const messages = yield* runOpenCodeSdk("session.messages", () =>
3816 context.client.session.revert({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({
3817 sessionID: context.openCodeSessionId,3836 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),
3819 }),3865 }),
3820 ).pipe(Effect.mapError(toRequestError));3866 ).pipe(Effect.mapError(toRequestError));
3821 // Native revert can move the boundary to the preceding user message.3867 yield* clearPendingOpenCodeRequests(context, { type: "session.fork" });
3822 return yield* readThread(threadId);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 };
3823 }3898 }
38243899
3825 return snapshot;3900 return snapshot;
apps/web/src/components/ChatView.tsx+52-11
...@@ -6533,8 +6533,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -6533,8 +6533,18 @@ export default function ChatView(props: ChatViewProps) {
6533 return () => window.removeEventListener("paste", handler, true);6533 return () => window.removeEventListener("paste", handler, true);
6534 }, [activeThreadId, composerRef]);6534 }, [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
6536 const onRevertToTurnCount = useCallback(6546 const onRevertToTurnCount = useCallback(
6537 async (turnCount: number, messageId: MessageId) => {6547 async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => {
6538 const localApi = readLocalApi();6548 const localApi = readLocalApi();
6539 if (!localApi || !activeThread || isRevertingCheckpoint) return;6549 if (!localApi || !activeThread || isRevertingCheckpoint) return;
6540 const message = activeThread.messages.find((message) => message.id === messageId);6550 const message = activeThread.messages.find((message) => message.id === messageId);
...@@ -6558,15 +6568,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -6558,15 +6568,8 @@ export default function ChatView(props: ChatViewProps) {
6558 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");6568 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
6559 return;6569 return;
6560 }6570 }
6561 const confirmed = await localApi.dialogs.confirm(6571 if (restoreFiles === undefined) {
6562 [6572 setPendingRevert({ turnCount, messageId, routeThreadKey });
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) {
6570 return;6573 return;
6571 }6574 }
65726575
...@@ -6599,7 +6602,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6599,7 +6602,7 @@ export default function ChatView(props: ChatViewProps) {
6599 await waitForRevertedMessage(routeThreadRef, messageId, turnCount, async () => {6602 await waitForRevertedMessage(routeThreadRef, messageId, turnCount, async () => {
6600 const result = await revertThreadCheckpoint({6603 const result = await revertThreadCheckpoint({
6601 environmentId,6604 environmentId,
6602 input: { threadId: activeThread.id, turnCount },6605 input: { threadId: activeThread.id, turnCount, restoreFiles },
6603 });6606 });
6604 if (result._tag === "Failure") throw squashAtomCommandFailure(result);6607 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
6605 });6608 });
...@@ -9144,6 +9147,44 @@ export default function ChatView(props: ChatViewProps) {...@@ -9144,6 +9147,44 @@ export default function ChatView(props: ChatViewProps) {
9144 </RightPanelSheet>9147 </RightPanelSheet>
9145 ) : null}9148 ) : 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>
9147 <LinkPullRequestDialogHost />9188 <LinkPullRequestDialogHost />
9148 {expandedImage && (9189 {expandedImage && (
9149 <ExpandedImageDialog9190 <ExpandedImageDialog
docs/user/composer.md+4-3
...@@ -72,9 +72,10 @@ into a normal draft....@@ -72,9 +72,10 @@ into a normal draft.
72## Edit an earlier prompt72## Edit an earlier prompt
7373
74On web and desktop, choose **Edit from here** beneath a sent message to rewind74On web and desktop, choose **Edit from here** beneath a sent message to rewind
75the conversation and workspace files to before that message. The selected prompt75the conversation to before that message. Choose **Revert and keep changes** to
76and its attachments return to the composer for editing and resending. Any unsent76leave workspace files as they are, or **Revert files too** to restore them as well.
77draft stays above the restored prompt.77The selected prompt and its attachments return to the composer for editing and
78resending. Any unsent draft stays above the restored prompt.
7879
79This removes the selected message and later conversation from the active thread80This removes the selected message and later conversation from the active thread
80and provider history. It does not undo external actions or separate provider81and 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";...@@ -24,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
24import {24import {
25 archiveThread,25 archiveThread,
26 createProject,26 createProject,
27 revertThreadCheckpoint,
27 reorderActiveThread,28 reorderActiveThread,
28 settleThread,29 settleThread,
29 stopThreadSession,30 stopThreadSession,
...@@ -101,6 +102,27 @@ describe("environment commands", () => {...@@ -101,6 +102,27 @@ describe("environment commands", () => {
101 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),102 }).pipe(Effect.provide(TEST_CRYPTO_LAYER)),
102 );103 );
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
104 it.effect("preserves caller metadata for idempotent queued commands", () =>126 it.effect("preserves caller metadata for idempotent queued commands", () =>
105 Effect.gen(function* () {127 Effect.gen(function* () {
106 const dispatched: ClientOrchestrationCommand[] = [];128 const dispatched: ClientOrchestrationCommand[] = [];
packages/client-runtime/src/operations/commands.ts+6-3
...@@ -53,7 +53,9 @@ export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;...@@ -53,7 +53,9 @@ export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">;
53export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;53export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">;
54export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;54export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">;
55export type DismissThreadUserInputInput = CommandInput<"thread.user-input.dismiss">;55export 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};
57export type StopThreadSessionInput = CommandInput<"thread.session.stop">;59export type StopThreadSessionInput = CommandInput<"thread.session.stop">;
5860
59type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand;61type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand;
...@@ -355,9 +357,10 @@ export const dismissThreadUserInput: (input: DismissThreadUserInputInput) => Com...@@ -355,9 +357,10 @@ export const dismissThreadUserInput: (input: DismissThreadUserInputInput) => Com
355export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect =357export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect =
356 Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) {358 Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) {
357 const metadata = yield* timestampedCommandMetadata(input);359 const metadata = yield* timestampedCommandMetadata(input);
360 const { restoreFiles, ...command } = input;
358 return yield* dispatch({361 return yield* dispatch({
359 ...input,362 ...command,
360 type: "thread.checkpoint.revert",363 type: restoreFiles === false ? "thread.conversation.revert" : "thread.checkpoint.revert",
361 commandId: metadata.commandId,364 commandId: metadata.commandId,
362 createdAt: metadata.createdAt,365 createdAt: metadata.createdAt,
363 });366 });
packages/contracts/src/orchestration.ts+10
...@@ -1287,6 +1287,13 @@ const ThreadCheckpointRevertCommand = Schema.Struct({...@@ -1287,6 +1287,13 @@ const ThreadCheckpointRevertCommand = Schema.Struct({
1287 createdAt: IsoDateTime,1287 createdAt: IsoDateTime,
1288});1288});
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
1290const ThreadSessionStopCommand = Schema.Struct({1297const ThreadSessionStopCommand = Schema.Struct({
1291 type: Schema.Literal("thread.session.stop"),1298 type: Schema.Literal("thread.session.stop"),
1292 commandId: CommandId,1299 commandId: CommandId,
...@@ -1327,6 +1334,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -1327,6 +1334,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
1327 ThreadUserInputRespondCommand,1334 ThreadUserInputRespondCommand,
1328 ThreadUserInputDismissCommand,1335 ThreadUserInputDismissCommand,
1329 ThreadCheckpointRevertCommand,1336 ThreadCheckpointRevertCommand,
1337 ThreadConversationRevertCommand,
1330 ThreadSessionStopCommand,1338 ThreadSessionStopCommand,
1331]);1339]);
1332export type DispatchableClientOrchestrationCommand =1340export type DispatchableClientOrchestrationCommand =
...@@ -1359,6 +1367,7 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -1359,6 +1367,7 @@ export const ClientOrchestrationCommand = Schema.Union([
1359 ThreadUserInputRespondCommand,1367 ThreadUserInputRespondCommand,
1360 ThreadUserInputDismissCommand,1368 ThreadUserInputDismissCommand,
1361 ThreadCheckpointRevertCommand,1369 ThreadCheckpointRevertCommand,
1370 ThreadConversationRevertCommand,
1362 ThreadSessionStopCommand,1371 ThreadSessionStopCommand,
1363]);1372]);
1364export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type;1373export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type;
...@@ -1762,6 +1771,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({...@@ -1762,6 +1771,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({
1762export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({1771export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({
1763 threadId: ThreadId,1772 threadId: ThreadId,
1764 turnCount: NonNegativeInt,1773 turnCount: NonNegativeInt,
1774 restoreFiles: Schema.optional(Schema.Boolean),
1765 createdAt: IsoDateTime,1775 createdAt: IsoDateTime,
1766});1776});
17671777