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 = {
8383 | ((sessionID: string) => Promise<Array<{ id: string }>>)
8484 | null,
8585 closeCalls: [] as string[],
86 revertMessageID: undefined as string | undefined,
8687 revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
8788 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
8889 messageFailures: 0,
......@@ -142,6 +143,7 @@ const runtimeMock = {
142143 this.state.sessionChildrenById.clear();
143144 this.state.sessionChildrenImplementation = null;
144145 this.state.closeCalls.length = 0;
146 this.state.revertMessageID = undefined;
145147 this.state.revertCalls.length = 0;
146148 this.state.messageCalls.length = 0;
147149 this.state.messageFailures = 0;
......@@ -262,6 +264,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
262264 return {
263265 data: {
264266 id: sessionID,
267 ...(runtimeMock.state.revertMessageID
268 ? { revert: { messageID: runtimeMock.state.revertMessageID } }
269 : {}),
265270 ...(directory ? { directory } : {}),
266271 ...(parentID ? { parentID } : {}),
267272 },
......@@ -371,17 +376,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
371376 ...(messageID ? { messageID } : {}),
372377 });
373378 if (!messageID) {
374 runtimeMock.state.messages = [];
375 return;
379 throw new Error("Expected messageID");
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 }
376388 }
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;
385389 },
386390 },
387391 event: {
......@@ -6316,7 +6320,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
63166320 }).pipe(Effect.provide(adapterLayer));
63176321 });
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", () =>
63206324 Effect.gen(function* () {
63216325 const adapter = yield* OpenCodeAdapter;
63226326 const threadId = asThreadId("thread-rollback-all");
......@@ -6327,22 +6331,62 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
63276331 });
63286332
63296333 runtimeMock.state.messages = [
6334 { info: { id: "user-1", role: "user" }, parts: [] },
63306335 {
63316336 info: { id: "assistant-1", role: "assistant" },
6332 parts: [],
6337 parts: [{ id: "part-1", type: "text", text: "first answer" }],
63336338 },
6339 { info: { id: "user-2", role: "user" }, parts: [] },
63346340 {
63356341 info: { id: "assistant-2", role: "assistant" },
6336 parts: [],
6342 parts: [{ id: "part-2", type: "text", text: "second answer" }],
63376343 },
63386344 ];
63396345
6340 const snapshot = yield* adapter.rollbackThread(threadId, 2);
6341
6342 NodeAssert.deepEqual(runtimeMock.state.revertCalls, [
6343 { sessionID: "http://127.0.0.1:9999/session" },
6344 ]);
6345 NodeAssert.deepEqual(snapshot.turns, []);
6346 for (const numTurns of [0, 1, 2, 3]) {
6347 runtimeMock.state.revertMessageID = undefined;
6348 runtimeMock.state.revertCalls.length = 0;
6349 const snapshot = yield* adapter.rollbackThread(threadId, numTurns);
6350 NodeAssert.deepEqual(
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, []);
63466390 }),
63476391 );
63486392
apps/server/src/provider/Layers/OpenCodeAdapter.ts+18-18
......@@ -3750,6 +3750,9 @@ export function makeOpenCodeAdapter(
37503750 const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(
37513751 function* (threadId) {
37523752 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));
37533756 const messages = yield* runOpenCodeSdk("session.messages", () =>
37543757 context.client.session.messages({
37553758 sessionID: context.openCodeSessionId,
......@@ -3758,6 +3761,7 @@ export function makeOpenCodeAdapter(
37583761
37593762 const turns: Array<OpenCodeTurnSnapshot> = [];
37603763 for (const entry of messages.data ?? []) {
3764 if (entry.info.id === session.data?.revert?.messageID) break;
37613765 if (entry.info.role === "assistant") {
37623766 turns.push({
37633767 id: TurnId.make(entry.info.id),
......@@ -3776,25 +3780,21 @@ export function makeOpenCodeAdapter(
37763780 const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
37773781 function* (threadId, numTurns) {
37783782 const context = yield* ensureSessionContext(sessions, threadId);
3779 const messages = yield* runOpenCodeSdk("session.messages", () =>
3780 context.client.session.messages({
3781 sessionID: context.openCodeSessionId,
3782 }),
3783 ).pipe(Effect.mapError(toRequestError));
3784
3785 const assistantMessages = (messages.data ?? []).filter(
3786 (entry) => entry.info.role === "assistant",
3787 );
3788 const targetIndex = assistantMessages.length - numTurns - 1;
3789 const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null;
3790 yield* runOpenCodeSdk("session.revert", () =>
3791 context.client.session.revert({
3792 sessionID: context.openCodeSessionId,
3793 ...(target ? { messageID: target.info.id } : {}),
3794 }),
3795 ).pipe(Effect.mapError(toRequestError));
3783 const snapshot = yield* readThread(threadId);
3784 const targetIndex = Math.max(0, snapshot.turns.length - numTurns);
3785 const target = snapshot.turns[targetIndex];
3786 if (target) {
3787 yield* runOpenCodeSdk("session.revert", () =>
3788 context.client.session.revert({
3789 sessionID: context.openCodeSessionId,
3790 messageID: target.id,
3791 }),
3792 ).pipe(Effect.mapError(toRequestError));
3793 // Native revert can move the boundary to the preceding user message.
3794 return yield* readThread(threadId);
3795 }
37963796
3797 return yield* readThread(threadId);
3797 return snapshot;
37983798 },
37993799 );
38003800