From 1e5f04caa377f48fc356e23baca05ddda96f7eb5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 04:52:45 +0000 Subject: [PATCH 1/3] fix(opencode): revert from the first removed assistant message --- .../provider/Layers/OpenCodeAdapter.test.ts | 43 +++++++++++-------- .../src/provider/Layers/OpenCodeAdapter.ts | 27 ++++++++---- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index fb0e9aa9ef2d..290c9a13c277 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -372,17 +372,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ...(messageID ? { messageID } : {}), }); if (!messageID) { - runtimeMock.state.messages = []; - return; + throw new Error("Expected messageID"); } - - const targetIndex = runtimeMock.state.messages.findIndex( - (entry) => entry.info.id === messageID, - ); - runtimeMock.state.messages = - targetIndex >= 0 - ? runtimeMock.state.messages.slice(0, targetIndex + 1) - : runtimeMock.state.messages; }, }, event: { @@ -6316,7 +6307,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.provide(adapterLayer)); }); - it.effect("reverts the full thread when rollback removes every assistant turn", () => + it.effect("reverts the first removed assistant message and returns only retained turns", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-rollback-all"); @@ -6337,12 +6328,30 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }, ]; - const snapshot = yield* adapter.rollbackThread(threadId, 2); - - NodeAssert.deepEqual(runtimeMock.state.revertCalls, [ - { sessionID: "http://127.0.0.1:9999/session" }, - ]); - NodeAssert.deepEqual(snapshot.turns, []); + for (const numTurns of [0, 1, 2, 3]) { + runtimeMock.state.revertCalls.length = 0; + const snapshot = yield* adapter.rollbackThread(threadId, numTurns); + NodeAssert.deepEqual( + runtimeMock.state.revertCalls, + numTurns === 0 + ? [] + : [ + { + sessionID: "http://127.0.0.1:9999/session", + messageID: numTurns === 1 ? "assistant-2" : "assistant-1", + }, + ], + ); + NodeAssert.deepEqual( + snapshot.turns.map((turn) => turn.id), + ["assistant-1", "assistant-2"].slice(0, Math.max(0, 2 - numTurns)), + ); + } + runtimeMock.state.messages = []; + runtimeMock.state.revertCalls.length = 0; + const emptySnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.deepEqual(runtimeMock.state.revertCalls, []); + NodeAssert.deepEqual(emptySnapshot.turns, []); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index ea777d1e15f9..61b7d4de329f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3785,16 +3785,25 @@ export function makeOpenCodeAdapter( const assistantMessages = (messages.data ?? []).filter( (entry) => entry.info.role === "assistant", ); - const targetIndex = assistantMessages.length - numTurns - 1; - const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* runOpenCodeSdk("session.revert", () => - context.client.session.revert({ - sessionID: context.openCodeSessionId, - ...(target ? { messageID: target.info.id } : {}), - }), - ).pipe(Effect.mapError(toRequestError)); + const targetIndex = Math.max(0, assistantMessages.length - numTurns); + const target = assistantMessages[targetIndex]; + if (target) { + yield* runOpenCodeSdk("session.revert", () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + messageID: target.info.id, + }), + ).pipe(Effect.mapError(toRequestError)); + } - return yield* readThread(threadId); + // OpenCode marks a revert boundary but retains the messages until the next prompt. + return { + threadId, + turns: assistantMessages.slice(0, targetIndex).map((entry) => ({ + id: TurnId.make(entry.info.id), + items: [entry.info, ...entry.parts], + })), + }; }, ); From 433ff2aa498ee22695fb30ab6974c57772c6ad10 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:25:47 +0000 Subject: [PATCH 2/3] fix(opencode): honor existing native revert boundaries --- .../provider/Layers/OpenCodeAdapter.test.ts | 17 +++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 25 +++++++------------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 290c9a13c277..1dd7d90cb5e9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -84,6 +84,7 @@ const runtimeMock = { | ((sessionID: string) => Promise>) | null, closeCalls: [] as string[], + revertMessageID: undefined as string | undefined, revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, messageCalls: [] as Array<{ sessionID: string; messageID: string }>, messageFailures: 0, @@ -143,6 +144,7 @@ const runtimeMock = { this.state.sessionChildrenById.clear(); this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; + this.state.revertMessageID = undefined; this.state.revertCalls.length = 0; this.state.messageCalls.length = 0; this.state.messageFailures = 0; @@ -263,6 +265,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { return { data: { id: sessionID, + ...(runtimeMock.state.revertMessageID + ? { revert: { messageID: runtimeMock.state.revertMessageID } } + : {}), ...(directory ? { directory } : {}), ...(parentID ? { parentID } : {}), }, @@ -374,6 +379,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { if (!messageID) { throw new Error("Expected messageID"); } + runtimeMock.state.revertMessageID = messageID; }, }, event: { @@ -6329,6 +6335,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ]; for (const numTurns of [0, 1, 2, 3]) { + runtimeMock.state.revertMessageID = undefined; runtimeMock.state.revertCalls.length = 0; const snapshot = yield* adapter.rollbackThread(threadId, numTurns); NodeAssert.deepEqual( @@ -6347,6 +6354,16 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ["assistant-1", "assistant-2"].slice(0, Math.max(0, 2 - numTurns)), ); } + runtimeMock.state.revertMessageID = undefined; + for (const remaining of [1, 0]) { + const snapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.equal(snapshot.turns.length, remaining); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns); + } + NodeAssert.deepEqual( + runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID), + ["assistant-2", "assistant-1"], + ); runtimeMock.state.messages = []; runtimeMock.state.revertCalls.length = 0; const emptySnapshot = yield* adapter.rollbackThread(threadId, 1); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 61b7d4de329f..eef39c1fcca2 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3750,6 +3750,9 @@ export function makeOpenCodeAdapter( const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = yield* ensureSessionContext(sessions, threadId); + const session = yield* runOpenCodeSdk("session.get", () => + context.client.session.get({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); const messages = yield* runOpenCodeSdk("session.messages", () => context.client.session.messages({ sessionID: context.openCodeSessionId, @@ -3758,6 +3761,7 @@ export function makeOpenCodeAdapter( const turns: Array = []; for (const entry of messages.data ?? []) { + if (entry.info.id === session.data?.revert?.messageID) break; if (entry.info.role === "assistant") { turns.push({ id: TurnId.make(entry.info.id), @@ -3776,22 +3780,14 @@ export function makeOpenCodeAdapter( const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = yield* ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe(Effect.mapError(toRequestError)); - - const assistantMessages = (messages.data ?? []).filter( - (entry) => entry.info.role === "assistant", - ); - const targetIndex = Math.max(0, assistantMessages.length - numTurns); - const target = assistantMessages[targetIndex]; + const snapshot = yield* readThread(threadId); + const targetIndex = Math.max(0, snapshot.turns.length - numTurns); + const target = snapshot.turns[targetIndex]; if (target) { yield* runOpenCodeSdk("session.revert", () => context.client.session.revert({ sessionID: context.openCodeSessionId, - messageID: target.info.id, + messageID: target.id, }), ).pipe(Effect.mapError(toRequestError)); } @@ -3799,10 +3795,7 @@ export function makeOpenCodeAdapter( // OpenCode marks a revert boundary but retains the messages until the next prompt. return { threadId, - turns: assistantMessages.slice(0, targetIndex).map((entry) => ({ - id: TurnId.make(entry.info.id), - items: [entry.info, ...entry.parts], - })), + turns: snapshot.turns.slice(0, targetIndex), }; }, ); From 0c7358f56ba81e4bd76cd5032cddf15fe1f627c1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 5 Sep 2026 05:39:27 +0000 Subject: [PATCH 3/3] fix(opencode): return the actual native rollback snapshot --- .../provider/Layers/OpenCodeAdapter.test.ts | 24 ++++++++++++++++--- .../src/provider/Layers/OpenCodeAdapter.ts | 8 +++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1dd7d90cb5e9..1c90b89f2e7a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -379,7 +379,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { if (!messageID) { throw new Error("Expected messageID"); } - runtimeMock.state.revertMessageID = messageID; + let lastUserID: string | undefined; + for (const entry of runtimeMock.state.messages) { + if (entry.info.role === "user") lastUserID = entry.info.id; + if (entry.info.id === messageID && entry.parts.length > 0) { + runtimeMock.state.revertMessageID = lastUserID ?? messageID; + break; + } + } }, }, event: { @@ -6324,13 +6331,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); runtimeMock.state.messages = [ + { info: { id: "user-1", role: "user" }, parts: [] }, { info: { id: "assistant-1", role: "assistant" }, - parts: [], + parts: [{ id: "part-1", type: "text", text: "first answer" }], }, + { info: { id: "user-2", role: "user" }, parts: [] }, { info: { id: "assistant-2", role: "assistant" }, - parts: [], + parts: [{ id: "part-2", type: "text", text: "second answer" }], }, ]; @@ -6364,6 +6373,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID), ["assistant-2", "assistant-1"], ); + runtimeMock.state.revertMessageID = undefined; + runtimeMock.state.messages = runtimeMock.state.messages.filter( + (entry) => entry.info.id !== "user-2", + ); + const sharedUserSnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.equal(runtimeMock.state.revertMessageID, "user-1"); + NodeAssert.deepEqual(sharedUserSnapshot.turns, []); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []); + runtimeMock.state.messages = []; runtimeMock.state.revertCalls.length = 0; const emptySnapshot = yield* adapter.rollbackThread(threadId, 1); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index eef39c1fcca2..4f4f7ba6be62 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3790,13 +3790,11 @@ export function makeOpenCodeAdapter( messageID: target.id, }), ).pipe(Effect.mapError(toRequestError)); + // Native revert can move the boundary to the preceding user message. + return yield* readThread(threadId); } - // OpenCode marks a revert boundary but retains the messages until the next prompt. - return { - threadId, - turns: snapshot.turns.slice(0, targetIndex), - }; + return snapshot; }, );