authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-05 14:57:27-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 14:57:27-04:00
log4ca71463a289e7b96b5776b6f6bbdecefe4757e4
tree94bc804c6836910cbf6429fd1849c11e6f105024
parent0d8a91a25a35618c7cae1e02791e491afa1f0a00
signature Signed by PGP key B5690EEEBB952194

fix(opencode): revert from the first removed assistant message (#9924)


2 files changed, 81 insertions(+), 37 deletions(-)

apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+63-19
...@@ -83,6 +83,7 @@ const runtimeMock = {...@@ -83,6 +83,7 @@ const runtimeMock = {
83 | ((sessionID: string) => Promise<Array<{ id: string }>>)83 | ((sessionID: string) => Promise<Array<{ id: string }>>)
84 | null,84 | null,
85 closeCalls: [] as string[],85 closeCalls: [] as string[],
86 revertMessageID: undefined as string | undefined,
86 revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,87 revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
87 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,88 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
88 messageFailures: 0,89 messageFailures: 0,
...@@ -142,6 +143,7 @@ const runtimeMock = {...@@ -142,6 +143,7 @@ const runtimeMock = {
142 this.state.sessionChildrenById.clear();143 this.state.sessionChildrenById.clear();
143 this.state.sessionChildrenImplementation = null;144 this.state.sessionChildrenImplementation = null;
144 this.state.closeCalls.length = 0;145 this.state.closeCalls.length = 0;
146 this.state.revertMessageID = undefined;
145 this.state.revertCalls.length = 0;147 this.state.revertCalls.length = 0;
146 this.state.messageCalls.length = 0;148 this.state.messageCalls.length = 0;
147 this.state.messageFailures = 0;149 this.state.messageFailures = 0;
...@@ -262,6 +264,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -262,6 +264,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
262 return {264 return {
263 data: {265 data: {
264 id: sessionID,266 id: sessionID,
267 ...(runtimeMock.state.revertMessageID
268 ? { revert: { messageID: runtimeMock.state.revertMessageID } }
269 : {}),
265 ...(directory ? { directory } : {}),270 ...(directory ? { directory } : {}),
266 ...(parentID ? { parentID } : {}),271 ...(parentID ? { parentID } : {}),
267 },272 },
...@@ -371,17 +376,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -371,17 +376,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
371 ...(messageID ? { messageID } : {}),376 ...(messageID ? { messageID } : {}),
372 });377 });
373 if (!messageID) {378 if (!messageID) {
374 runtimeMock.state.messages = [];379 throw new Error("Expected messageID");
375 return;380 }
381 let lastUserID: string | undefined;
382 for (const entry of runtimeMock.state.messages) {
383 if (entry.info.role === "user") lastUserID = entry.info.id;
384 if (entry.info.id === messageID && entry.parts.length > 0) {
385 runtimeMock.state.revertMessageID = lastUserID ?? messageID;
386 break;
387 }
376 }388 }
377
378 const targetIndex = runtimeMock.state.messages.findIndex(
379 (entry) => entry.info.id === messageID,
380 );
381 runtimeMock.state.messages =
382 targetIndex >= 0
383 ? runtimeMock.state.messages.slice(0, targetIndex + 1)
384 : runtimeMock.state.messages;
385 },389 },
386 },390 },
387 event: {391 event: {
...@@ -6316,7 +6320,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -6316,7 +6320,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
6316 }).pipe(Effect.provide(adapterLayer));6320 }).pipe(Effect.provide(adapterLayer));
6317 });6321 });
63186322
6319 it.effect("reverts the full thread when rollback removes every assistant turn", () =>6323 it.effect("reverts the first removed assistant message and returns only retained turns", () =>
6320 Effect.gen(function* () {6324 Effect.gen(function* () {
6321 const adapter = yield* OpenCodeAdapter;6325 const adapter = yield* OpenCodeAdapter;
6322 const threadId = asThreadId("thread-rollback-all");6326 const threadId = asThreadId("thread-rollback-all");
...@@ -6327,22 +6331,62 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -6327,22 +6331,62 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
6327 });6331 });
63286332
6329 runtimeMock.state.messages = [6333 runtimeMock.state.messages = [
6334 { info: { id: "user-1", role: "user" }, parts: [] },
6330 {6335 {
6331 info: { id: "assistant-1", role: "assistant" },6336 info: { id: "assistant-1", role: "assistant" },
6332 parts: [],6337 parts: [{ id: "part-1", type: "text", text: "first answer" }],
6333 },6338 },
6339 { info: { id: "user-2", role: "user" }, parts: [] },
6334 {6340 {
6335 info: { id: "assistant-2", role: "assistant" },6341 info: { id: "assistant-2", role: "assistant" },
6336 parts: [],6342 parts: [{ id: "part-2", type: "text", text: "second answer" }],
6337 },6343 },
6338 ];6344 ];
63396345
6340 const snapshot = yield* adapter.rollbackThread(threadId, 2);6346 for (const numTurns of [0, 1, 2, 3]) {
63416347 runtimeMock.state.revertMessageID = undefined;
6342 NodeAssert.deepEqual(runtimeMock.state.revertCalls, [6348 runtimeMock.state.revertCalls.length = 0;
6343 { sessionID: "http://127.0.0.1:9999/session" },6349 const snapshot = yield* adapter.rollbackThread(threadId, numTurns);
6344 ]);6350 NodeAssert.deepEqual(
6345 NodeAssert.deepEqual(snapshot.turns, []);6351 runtimeMock.state.revertCalls,
6352 numTurns === 0
6353 ? []
6354 : [
6355 {
6356 sessionID: "http://127.0.0.1:9999/session",
6357 messageID: numTurns === 1 ? "assistant-2" : "assistant-1",
6358 },
6359 ],
6360 );
6361 NodeAssert.deepEqual(
6362 snapshot.turns.map((turn) => turn.id),
6363 ["assistant-1", "assistant-2"].slice(0, Math.max(0, 2 - numTurns)),
6364 );
6365 }
6366 runtimeMock.state.revertMessageID = undefined;
6367 for (const remaining of [1, 0]) {
6368 const snapshot = yield* adapter.rollbackThread(threadId, 1);
6369 NodeAssert.equal(snapshot.turns.length, remaining);
6370 NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns);
6371 }
6372 NodeAssert.deepEqual(
6373 runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID),
6374 ["assistant-2", "assistant-1"],
6375 );
6376 runtimeMock.state.revertMessageID = undefined;
6377 runtimeMock.state.messages = runtimeMock.state.messages.filter(
6378 (entry) => entry.info.id !== "user-2",
6379 );
6380 const sharedUserSnapshot = yield* adapter.rollbackThread(threadId, 1);
6381 NodeAssert.equal(runtimeMock.state.revertMessageID, "user-1");
6382 NodeAssert.deepEqual(sharedUserSnapshot.turns, []);
6383 NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []);
6384
6385 runtimeMock.state.messages = [];
6386 runtimeMock.state.revertCalls.length = 0;
6387 const emptySnapshot = yield* adapter.rollbackThread(threadId, 1);
6388 NodeAssert.deepEqual(runtimeMock.state.revertCalls, []);
6389 NodeAssert.deepEqual(emptySnapshot.turns, []);
6346 }),6390 }),
6347 );6391 );
63486392
apps/server/src/provider/Layers/OpenCodeAdapter.ts+18-18
...@@ -3750,6 +3750,9 @@ export function makeOpenCodeAdapter(...@@ -3750,6 +3750,9 @@ export function makeOpenCodeAdapter(
3750 const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(3750 const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(
3751 function* (threadId) {3751 function* (threadId) {
3752 const context = yield* ensureSessionContext(sessions, threadId);3752 const context = yield* ensureSessionContext(sessions, threadId);
3753 const session = yield* runOpenCodeSdk("session.get", () =>
3754 context.client.session.get({ sessionID: context.openCodeSessionId }),
3755 ).pipe(Effect.mapError(toRequestError));
3753 const messages = yield* runOpenCodeSdk("session.messages", () =>3756 const messages = yield* runOpenCodeSdk("session.messages", () =>
3754 context.client.session.messages({3757 context.client.session.messages({
3755 sessionID: context.openCodeSessionId,3758 sessionID: context.openCodeSessionId,
...@@ -3758,6 +3761,7 @@ export function makeOpenCodeAdapter(...@@ -3758,6 +3761,7 @@ export function makeOpenCodeAdapter(
37583761
3759 const turns: Array<OpenCodeTurnSnapshot> = [];3762 const turns: Array<OpenCodeTurnSnapshot> = [];
3760 for (const entry of messages.data ?? []) {3763 for (const entry of messages.data ?? []) {
3764 if (entry.info.id === session.data?.revert?.messageID) break;
3761 if (entry.info.role === "assistant") {3765 if (entry.info.role === "assistant") {
3762 turns.push({3766 turns.push({
3763 id: TurnId.make(entry.info.id),3767 id: TurnId.make(entry.info.id),
...@@ -3776,25 +3780,21 @@ export function makeOpenCodeAdapter(...@@ -3776,25 +3780,21 @@ export function makeOpenCodeAdapter(
3776 const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(3780 const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
3777 function* (threadId, numTurns) {3781 function* (threadId, numTurns) {
3778 const context = yield* ensureSessionContext(sessions, threadId);3782 const context = yield* ensureSessionContext(sessions, threadId);
3779 const messages = yield* runOpenCodeSdk("session.messages", () =>3783 const snapshot = yield* readThread(threadId);
3780 context.client.session.messages({3784 const targetIndex = Math.max(0, snapshot.turns.length - numTurns);
3781 sessionID: context.openCodeSessionId,3785 const target = snapshot.turns[targetIndex];
3782 }),3786 if (target) {
3783 ).pipe(Effect.mapError(toRequestError));3787 yield* runOpenCodeSdk("session.revert", () =>
37843788 context.client.session.revert({
3785 const assistantMessages = (messages.data ?? []).filter(3789 sessionID: context.openCodeSessionId,
3786 (entry) => entry.info.role === "assistant",3790 messageID: target.id,
3787 );3791 }),
3788 const targetIndex = assistantMessages.length - numTurns - 1;3792 ).pipe(Effect.mapError(toRequestError));
3789 const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null;3793 // Native revert can move the boundary to the preceding user message.
3790 yield* runOpenCodeSdk("session.revert", () =>3794 return yield* readThread(threadId);
3791 context.client.session.revert({3795 }
3792 sessionID: context.openCodeSessionId,
3793 ...(target ? { messageID: target.info.id } : {}),
3794 }),
3795 ).pipe(Effect.mapError(toRequestError));
37963796
3797 return yield* readThread(threadId);3797 return snapshot;
3798 },3798 },
3799 );3799 );
38003800