| author | |
| committer | |
| log | efccda9ac9230db22b36990cffabdad218fa41b0 |
| tree | 42935712a0ec5743bc48a6073ecfeaf8aac1a822 |
| parent | 25da986037a99d1e87bf1ed51aa58d7babee064a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1683 | }), |
| 1684 | 1684 | ); |
| 1685 | 1685 | |
| 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"; | |
| 1689 | 1698 | |
| 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"), | |
| 1696 | 1703 | 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 | ); | |
| 1707 | 1716 | |
| 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 | ); | |
| 1736 | 1749 | |
| 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; | |
| 1746 | 1762 | |
| 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 | ); | |
| 1752 | 1772 | |
| 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 | ); | |
| 1766 | 1812 | |
| 1767 | 1813 | it("executes provider revert and emits thread.reverted for claude sessions", async () => { |
| 1768 | 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 | 704 | thread, |
| 705 | 705 | projects: yield* resolveThreadProjects(thread.projectId), |
| 706 | 706 | 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 | ); | |
| 717 | 712 | |
| 718 | 713 | const currentTurnCount = thread.checkpoints.reduce( |
| 719 | 714 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), |
| ... | ... | @@ -730,43 +725,55 @@ const make = Effect.gen(function* () { |
| 730 | 725 | return; |
| 731 | 726 | } |
| 732 | 727 | |
| 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); | |
| 739 | 729 | |
| 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 | } | |
| 749 | 740 | |
| 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 | } | |
| 751 | 757 | |
| 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 | } | |
| 766 | 772 | |
| 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 | } | |
| 770 | 777 | |
| 771 | 778 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); |
| 772 | 779 | if (rolledBackTurns > 0) { |
| ... | ... | @@ -783,7 +790,7 @@ const make = Effect.gen(function* () { |
| 783 | 790 | } |
| 784 | 791 | } |
| 785 | 792 | |
| 786 | if (staleCheckpointRefs.length > 0) { | |
| 793 | if (checkpointCwd && staleCheckpointRefs.length > 0) { | |
| 787 | 794 | yield* checkpointStore.deleteCheckpointRefs({ |
| 788 | 795 | cwd: checkpointCwd, |
| 789 | 796 | checkpointRefs: staleCheckpointRefs, |
apps/server/src/orchestration/decider.ts+2| ... | ... | @@ -1640,6 +1640,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1640 | 1640 | }; |
| 1641 | 1641 | } |
| 1642 | 1642 | |
| 1643 | case "thread.conversation.revert": | |
| 1643 | 1644 | case "thread.checkpoint.revert": { |
| 1644 | 1645 | yield* requireThread({ |
| 1645 | 1646 | readModel, |
| ... | ... | @@ -1657,6 +1658,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 1657 | 1658 | payload: { |
| 1658 | 1659 | threadId: command.threadId, |
| 1659 | 1660 | turnCount: command.turnCount, |
| 1661 | ...(command.type === "thread.conversation.revert" ? { restoreFiles: false } : {}), | |
| 1660 | 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 | 97 | promptEchoEvents: [] as Array<unknown>, |
| 98 | 98 | closeError: null as Error | null, |
| 99 | 99 | messages: [] as MessageEntry[], |
| 100 | forkMessagesBySession: new Map<string, MessageEntry[]>(), | |
| 101 | forkPreservesBoundary: true, | |
| 100 | 102 | subscribedEvents: [] as Array<unknown | Promise<unknown>>, |
| 101 | 103 | eventSubscribeObserved: null as (() => void) | null, |
| 102 | 104 | eventStreamError: null as ((cause: unknown) => void) | null, |
| ... | ... | @@ -128,7 +130,7 @@ const runtimeMock = { |
| 128 | 130 | permissionListImplementation: null as (() => Promise<Array<PermissionRequest>>) | null, |
| 129 | 131 | questionListImplementation: null as (() => Promise<Array<QuestionRequest>>) | null, |
| 130 | 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 | 135 | reset() { |
| 134 | 136 | this.state.startCalls.length = 0; |
| ... | ... | @@ -157,6 +159,8 @@ const runtimeMock = { |
| 157 | 159 | this.state.promptEchoEvents.length = 0; |
| 158 | 160 | this.state.closeError = null; |
| 159 | 161 | this.state.messages = []; |
| 162 | this.state.forkMessagesBySession.clear(); | |
| 163 | this.state.forkPreservesBoundary = true; | |
| 160 | 164 | this.state.subscribedEvents = []; |
| 161 | 165 | this.state.eventSubscribeObserved = null; |
| 162 | 166 | this.state.eventStreamError = null; |
| ... | ... | @@ -264,7 +268,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 264 | 268 | return { |
| 265 | 269 | data: { |
| 266 | 270 | id: sessionID, |
| 267 | ...(runtimeMock.state.revertMessageID | |
| 271 | ...(runtimeMock.state.revertMessageID && | |
| 272 | !runtimeMock.state.forkMessagesBySession.has(sessionID) | |
| 268 | 273 | ? { revert: { messageID: runtimeMock.state.revertMessageID } } |
| 269 | 274 | : {}), |
| 270 | 275 | ...(directory ? { directory } : {}), |
| ... | ... | @@ -276,10 +281,37 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 276 | 281 | runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); |
| 277 | 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 | 293 | // Fork clones history into a new session bound to the directory. |
| 281 | 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 | 315 | if (directory) { |
| 284 | 316 | runtimeMock.state.sessionDirectoryById.set(forkedId, directory); |
| 285 | 317 | } |
| ... | ... | @@ -337,7 +369,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 337 | 369 | typeof input.sessionID === "string" && |
| 338 | 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 | 376 | info: { id: input.messageID, role: "user" }, |
| 342 | 377 | parts: [], |
| 343 | 378 | }); |
| ... | ... | @@ -355,14 +390,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 355 | 390 | runtimeMock.state.summarizeCalls.push(input); |
| 356 | 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 | 397 | message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => { |
| 360 | 398 | runtimeMock.state.messageCalls.push({ sessionID, messageID }); |
| 361 | 399 | if (runtimeMock.state.messageFailures > 0) { |
| 362 | 400 | runtimeMock.state.messageFailures -= 1; |
| 363 | 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 | 406 | if (!message) { |
| 367 | 407 | throw new Error(`Message not found: ${messageID}`, { |
| 368 | 408 | cause: { status: 404, body: { name: "NotFoundError" } }, |
| ... | ... | @@ -6333,7 +6373,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 6333 | 6373 | }).pipe(Effect.provide(adapterLayer)); |
| 6334 | 6374 | }); |
| 6335 | 6375 | |
| 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 | 6377 | Effect.gen(function* () { |
| 6338 | 6378 | const adapter = yield* OpenCodeAdapter; |
| 6339 | 6379 | const threadId = asThreadId("thread-rollback-all"); |
| ... | ... | @@ -6356,49 +6396,116 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { |
| 6356 | 6396 | }, |
| 6357 | 6397 | ]; |
| 6358 | 6398 | |
| 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 | 6412 | 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; | |
| 6362 | 6416 | const snapshot = yield* adapter.rollbackThread(threadId, numTurns); |
| 6363 | 6417 | NodeAssert.deepEqual( |
| 6364 | runtimeMock.state.revertCalls, | |
| 6418 | runtimeMock.state.forkCalls.map(({ sessionID, messageID }) => ({ sessionID, messageID })), | |
| 6365 | 6419 | numTurns === 0 |
| 6366 | 6420 | ? [] |
| 6367 | 6421 | : [ |
| 6368 | 6422 | { |
| 6369 | 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 | 6428 | NodeAssert.deepEqual( |
| 6375 | 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 | 6438 | for (const remaining of [1, 0]) { |
| 6381 | 6439 | const snapshot = yield* adapter.rollbackThread(threadId, 1); |
| 6382 | 6440 | NodeAssert.equal(snapshot.turns.length, remaining); |
| 6383 | 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 | 6456 | 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"], | |
| 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 | 6497 | runtimeMock.state.messages = runtimeMock.state.messages.filter( |
| 6391 | 6498 | (entry) => entry.info.id !== "user-2", |
| 6392 | 6499 | ); |
| 6393 | 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 | 6502 | NodeAssert.deepEqual(sharedUserSnapshot.turns, []); |
| 6396 | 6503 | NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []); |
| 6397 | 6504 | |
| 6398 | 6505 | runtimeMock.state.messages = []; |
| 6399 | runtimeMock.state.revertCalls.length = 0; | |
| 6506 | runtimeMock.state.forkCalls.length = 0; | |
| 6400 | 6507 | const emptySnapshot = yield* adapter.rollbackThread(threadId, 1); |
| 6401 | NodeAssert.deepEqual(runtimeMock.state.revertCalls, []); | |
| 6508 | NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); | |
| 6402 | 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 | 338 | readonly client: OpencodeClient; |
| 339 | 339 | readonly server: OpenCodeServerConnection; |
| 340 | 340 | readonly directory: string; |
| 341 | readonly openCodeSessionId: string; | |
| 341 | openCodeSessionId: string; | |
| 342 | 342 | readonly relatedSessionIds: Set<string>; |
| 343 | 343 | readonly resolvedRequestIds: Set<string>; |
| 344 | 344 | readonly autoRepliedRequestIds: Set<string>; |
| ... | ... | @@ -3812,14 +3812,89 @@ export function makeOpenCodeAdapter( |
| 3812 | 3812 | const targetIndex = Math.max(0, snapshot.turns.length - numTurns); |
| 3813 | 3813 | const target = snapshot.turns[targetIndex]; |
| 3814 | 3814 | 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({ | |
| 3817 | 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 | 3866 | ).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 | }; | |
| 3823 | 3898 | } |
| 3824 | 3899 | |
| 3825 | 3900 | return snapshot; |
apps/web/src/components/ChatView.tsx+52-11| ... | ... | @@ -6533,8 +6533,18 @@ export default function ChatView(props: ChatViewProps) { |
| 6533 | 6533 | return () => window.removeEventListener("paste", handler, true); |
| 6534 | 6534 | }, [activeThreadId, composerRef]); |
| 6535 | 6535 | |
| 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 | 6546 | const onRevertToTurnCount = useCallback( |
| 6537 | async (turnCount: number, messageId: MessageId) => { | |
| 6547 | async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => { | |
| 6538 | 6548 | const localApi = readLocalApi(); |
| 6539 | 6549 | if (!localApi || !activeThread || isRevertingCheckpoint) return; |
| 6540 | 6550 | const message = activeThread.messages.find((message) => message.id === messageId); |
| ... | ... | @@ -6558,15 +6568,8 @@ export default function ChatView(props: ChatViewProps) { |
| 6558 | 6568 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 6559 | 6569 | return; |
| 6560 | 6570 | } |
| 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 }); | |
| 6570 | 6573 | return; |
| 6571 | 6574 | } |
| 6572 | 6575 | |
| ... | ... | @@ -6599,7 +6602,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6599 | 6602 | await waitForRevertedMessage(routeThreadRef, messageId, turnCount, async () => { |
| 6600 | 6603 | const result = await revertThreadCheckpoint({ |
| 6601 | 6604 | environmentId, |
| 6602 | input: { threadId: activeThread.id, turnCount }, | |
| 6605 | input: { threadId: activeThread.id, turnCount, restoreFiles }, | |
| 6603 | 6606 | }); |
| 6604 | 6607 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); |
| 6605 | 6608 | }); |
| ... | ... | @@ -9144,6 +9147,44 @@ export default function ChatView(props: ChatViewProps) { |
| 9144 | 9147 | </RightPanelSheet> |
| 9145 | 9148 | ) : null} |
| 9146 | 9149 | |
| 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 | 9188 | <LinkPullRequestDialogHost /> |
| 9148 | 9189 | {expandedImage && ( |
| 9149 | 9190 | <ExpandedImageDialog |
docs/user/composer.md+4-3| ... | ... | @@ -72,9 +72,10 @@ into a normal draft. |
| 72 | 72 | ## Edit an earlier prompt |
| 73 | 73 | |
| 74 | 74 | On web and desktop, choose **Edit from here** beneath a sent message to rewind |
| 75 | the conversation and workspace files to before that message. The selected prompt | |
| 76 | and its attachments return to the composer for editing and resending. Any unsent | |
| 77 | draft stays above the restored prompt. | |
| 75 | the conversation to before that message. Choose **Revert and keep changes** to | |
| 76 | leave workspace files as they are, or **Revert files too** to restore them as well. | |
| 77 | The selected prompt and its attachments return to the composer for editing and | |
| 78 | resending. Any unsent draft stays above the restored prompt. | |
| 78 | 79 | |
| 79 | 80 | This removes the selected message and later conversation from the active thread |
| 80 | 81 | and 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 | 24 | import { |
| 25 | 25 | archiveThread, |
| 26 | 26 | createProject, |
| 27 | revertThreadCheckpoint, | |
| 27 | 28 | reorderActiveThread, |
| 28 | 29 | settleThread, |
| 29 | 30 | stopThreadSession, |
| ... | ... | @@ -101,6 +102,27 @@ describe("environment commands", () => { |
| 101 | 102 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 102 | 103 | ); |
| 103 | 104 | |
| 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 | 126 | it.effect("preserves caller metadata for idempotent queued commands", () => |
| 105 | 127 | Effect.gen(function* () { |
| 106 | 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 | 53 | export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; |
| 54 | 54 | export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; |
| 55 | 55 | export type DismissThreadUserInputInput = CommandInput<"thread.user-input.dismiss">; |
| 56 | export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; | |
| 56 | export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert"> & { | |
| 57 | readonly restoreFiles?: boolean; | |
| 58 | }; | |
| 57 | 59 | export type StopThreadSessionInput = CommandInput<"thread.session.stop">; |
| 58 | 60 | |
| 59 | 61 | type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; |
| ... | ... | @@ -355,9 +357,10 @@ export const dismissThreadUserInput: (input: DismissThreadUserInputInput) => Com |
| 355 | 357 | export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect = |
| 356 | 358 | Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) { |
| 357 | 359 | const metadata = yield* timestampedCommandMetadata(input); |
| 360 | const { restoreFiles, ...command } = input; | |
| 358 | 361 | return yield* dispatch({ |
| 359 | ...input, | |
| 360 | type: "thread.checkpoint.revert", | |
| 362 | ...command, | |
| 363 | type: restoreFiles === false ? "thread.conversation.revert" : "thread.checkpoint.revert", | |
| 361 | 364 | commandId: metadata.commandId, |
| 362 | 365 | createdAt: metadata.createdAt, |
| 363 | 366 | }); |
packages/contracts/src/orchestration.ts+10| ... | ... | @@ -1287,6 +1287,13 @@ const ThreadCheckpointRevertCommand = Schema.Struct({ |
| 1287 | 1287 | createdAt: IsoDateTime, |
| 1288 | 1288 | }); |
| 1289 | 1289 | |
| 1290 | // A separate command makes older servers reject history-only rewinds rather than | |
| 1291 | // ignoring an unfamiliar option and restoring files. | |
| 1292 | const ThreadConversationRevertCommand = Schema.Struct({ | |
| 1293 | ...ThreadCheckpointRevertCommand.fields, | |
| 1294 | type: Schema.Literal("thread.conversation.revert"), | |
| 1295 | }); | |
| 1296 | ||
| 1290 | 1297 | const ThreadSessionStopCommand = Schema.Struct({ |
| 1291 | 1298 | type: Schema.Literal("thread.session.stop"), |
| 1292 | 1299 | commandId: CommandId, |
| ... | ... | @@ -1327,6 +1334,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ |
| 1327 | 1334 | ThreadUserInputRespondCommand, |
| 1328 | 1335 | ThreadUserInputDismissCommand, |
| 1329 | 1336 | ThreadCheckpointRevertCommand, |
| 1337 | ThreadConversationRevertCommand, | |
| 1330 | 1338 | ThreadSessionStopCommand, |
| 1331 | 1339 | ]); |
| 1332 | 1340 | export type DispatchableClientOrchestrationCommand = |
| ... | ... | @@ -1359,6 +1367,7 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 1359 | 1367 | ThreadUserInputRespondCommand, |
| 1360 | 1368 | ThreadUserInputDismissCommand, |
| 1361 | 1369 | ThreadCheckpointRevertCommand, |
| 1370 | ThreadConversationRevertCommand, | |
| 1362 | 1371 | ThreadSessionStopCommand, |
| 1363 | 1372 | ]); |
| 1364 | 1373 | export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; |
| ... | ... | @@ -1762,6 +1771,7 @@ const ThreadUserInputResponseRequestedPayload = Schema.Struct({ |
| 1762 | 1771 | export const ThreadCheckpointRevertRequestedPayload = Schema.Struct({ |
| 1763 | 1772 | threadId: ThreadId, |
| 1764 | 1773 | turnCount: NonNegativeInt, |
| 1774 | restoreFiles: Schema.optional(Schema.Boolean), | |
| 1765 | 1775 | createdAt: IsoDateTime, |
| 1766 | 1776 | }); |
| 1767 | 1777 |