From 2944d667cff1def53cb5f9fa01ea4c1b3589bb70 Mon Sep 17 00:00:00 2001 From: Aaryan Rampal <76913929+aaryan-rampal@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:01:47 -0800 Subject: [PATCH 1/5] feat: add conversation-only revert capability --- .../pages/session/use-session-commands.tsx | 3 +- .../cmd/tui/routes/session/dialog-message.tsx | 35 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 + packages/opencode/src/session/revert.ts | 22 +- packages/opencode/src/session/session.sql.ts | 2 +- packages/opencode/src/session/session.ts | 1 + .../opencode/test/session/revert-mode.test.ts | 378 ++++++++++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 3 + 9 files changed, 442 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/test/session/revert-mode.test.ts diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 922299bec198..0b32fdd17c33 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -325,7 +325,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - await sdk.client.session.revert({ sessionID, messageID: next.id }) + const mode = info()?.revert?.mode + await sdk.client.session.revert({ sessionID, messageID: next.id, mode }) const prev = findLast(userMessages(), (x) => x.id < next.id) setActiveMessage(prev) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index aeea2f52ad0b..9ee44e2b84e3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -22,7 +22,39 @@ export function DialogMessage(props: { title="Message Actions" options={[ { - title: "Revert", + title: "Revert conversation", + value: "session.revert", + description: "undo messages only", + onSelect: (dialog) => { + const msg = message() + if (!msg) return + + sdk.client.session.revert({ + sessionID: props.sessionID, + messageID: msg.id, + mode: "conversation", + }) + + if (props.setPrompt) { + const parts = sync.data.part[msg.id] + const promptInfo = parts.reduce( + (agg, part) => { + if (part.type === "text") { + if (!part.synthetic) agg.input += part.text + } + if (part.type === "file") agg.parts.push(part) + return agg + }, + { input: "", parts: [] as PromptInfo["parts"] }, + ) + props.setPrompt(promptInfo) + } + + dialog.clear() + }, + }, + { + title: "Revert conversation and code", value: "session.revert", description: "undo messages and file changes", onSelect: (dialog) => { @@ -32,6 +64,7 @@ export function DialogMessage(props: { void sdk.client.session.revert({ sessionID: props.sessionID, messageID: msg.id, + mode: "conversation_and_code", }) if (props.setPrompt) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 183ce52cde4e..47fc7ef8a7a8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -596,9 +596,11 @@ export function Session() { prompt?.set({ input: "", parts: [] }) return } + const mode = session()?.revert?.mode void sdk.client.session.revert({ sessionID: route.sessionID, messageID: message.id, + mode, }) }, }, diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index da9952ccb245..78d14b8b4f9d 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -18,6 +18,7 @@ export const RevertInput = Schema.Struct({ sessionID: SessionID, messageID: MessageID, partID: Schema.optional(PartID), + mode: Schema.optional(Schema.Literal("conversation", "conversation_and_code")), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type RevertInput = Schema.Schema.Type @@ -45,6 +46,8 @@ export const layer = Layer.effect( let lastUser: MessageV2.User | undefined const session = yield* sessions.get(input.sessionID) + const mode = input.mode ?? session.revert?.mode ?? "conversation_and_code" + let rev: Session.Info["revert"] const patches: Snapshot.Patch[] = [] for (const msg of all) { @@ -71,17 +74,30 @@ export const layer = Layer.effect( if (!rev) return session + const range = all.filter((msg) => msg.info.id >= rev!.messageID) + const diffs = yield* summary.computeDiff({ messages: range }) + + if (mode === "conversation") { + return yield* sessions.setRevert({ + sessionID: input.sessionID, + revert: { ...rev, mode }, + summary: { + additions: diffs.reduce((sum, x) => sum + x.additions, 0), + deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + files: diffs.length, + }, + }) + } + rev.snapshot = session.revert?.snapshot ?? (yield* snap.track()) if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot) yield* snap.revert(patches) if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string) - const range = all.filter((msg) => msg.info.id >= rev!.messageID) - const diffs = yield* summary.computeDiff({ messages: range }) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) yield* sessions.setRevert({ sessionID: input.sessionID, - revert: rev, + revert: { ...rev, mode }, summary: { additions: diffs.reduce((sum, x) => sum + x.additions, 0), deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 863fb21d65c7..278db9dd44b2 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -32,7 +32,7 @@ export const SessionTable = sqliteTable( summary_deletions: integer(), summary_files: integer(), summary_diffs: text({ mode: "json" }).$type(), - revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), + revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string; mode?: "conversation" | "conversation_and_code" }>(), permission: text({ mode: "json" }).$type(), ...Timestamps, time_compacting: integer(), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index c376c8d1a936..3657c66490c4 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -154,6 +154,7 @@ const Revert = Schema.Struct({ partID: optionalOmitUndefined(PartID), snapshot: optionalOmitUndefined(Schema.String), diff: optionalOmitUndefined(Schema.String), + mode: optionalOmitUndefined(Schema.Literal("conversation", "conversation_and_code")), }) export const Info = Schema.Struct({ diff --git a/packages/opencode/test/session/revert-mode.test.ts b/packages/opencode/test/session/revert-mode.test.ts new file mode 100644 index 000000000000..93952a846a0d --- /dev/null +++ b/packages/opencode/test/session/revert-mode.test.ts @@ -0,0 +1,378 @@ +import { afterEach, beforeEach, expect, test } from "bun:test" +import path from "path" +import { Session } from "../../src/session" +import { SessionRevert } from "../../src/session/revert" +import { MessageV2 } from "../../src/session/message-v2" +import { Instance } from "../../src/project/instance" +import { Identifier } from "../../src/id/id" +import { Snapshot } from "../../src/snapshot" +import { tmpdir } from "../fixture/fixture" + +let cfg: string | undefined + +beforeEach(() => { + cfg = process.env.OPENCODE_CONFIG_CONTENT + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ snapshot: true }) +}) + +afterEach(() => { + if (cfg === undefined) { + delete process.env.OPENCODE_CONFIG_CONTENT + return + } + process.env.OPENCODE_CONFIG_CONTENT = cfg +}) + +test("conversation mode reverts messages but keeps files", async () => { + await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Create a session + const session = await Session.create({}) + const sessionID = session.id + + // Create a user message with file modification intent + const userMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + // Add text part to user message + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID, + type: "text", + text: "Modify test.txt", + }) + + // Create assistant response with patch + const assistantMsg: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg) + + // Create test file + const filePath = path.join(tmp.path, "test.txt") + const originalContent = "ORIGINAL" + await Bun.write(filePath, originalContent) + + // Add patch part to assistant message + const patchPart: MessageV2.PatchPart = { + id: Identifier.ascending("part"), + sessionID, + messageID: assistantMsg.id, + type: "patch", + hash: "abc123", + files: ["test.txt"], + } + await Session.updatePart(patchPart) + + // Modify file + const modifiedContent = "MODIFIED" + await Bun.write(filePath, modifiedContent) + + // Verify file is modified + expect(await Bun.file(filePath).text()).toBe(modifiedContent) + + // Revert with conversation mode + await SessionRevert.revert({ + sessionID, + messageID: assistantMsg.id, + mode: "conversation", + }) + + // Verify file content is unchanged + expect(await Bun.file(filePath).text()).toBe(modifiedContent) + + // Verify session.revert.mode is "conversation" + const updatedSession = await Session.get(sessionID) + expect(updatedSession.revert?.mode).toBe("conversation") + }, + }) +}) + +test("default revert still reverts conversation and code", async () => { + await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + + const userMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID, + type: "text", + text: "Modify test.txt", + }) + + const assistantMsg: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg) + + const filePath = path.join(tmp.path, "test.txt") + const originalContent = "ORIGINAL" + await Bun.write(filePath, originalContent) + + const hash = await Snapshot.track() + expect(hash).toBeTruthy() + + const modifiedContent = "MODIFIED" + await Bun.write(filePath, modifiedContent) + + const patch = await Snapshot.patch(hash!) + expect(patch.files.length).toBeGreaterThan(0) + await Session.updatePart({ + id: Identifier.ascending("part"), + sessionID, + messageID: assistantMsg.id, + type: "patch", + hash: patch.hash, + files: patch.files.map((x) => path.relative(tmp.path, x).replaceAll("\\", "/")), + }) + + expect(await Bun.file(filePath).text()).toBe(modifiedContent) + + await SessionRevert.revert({ + sessionID, + messageID: userMsg.id, + }) + + expect(await Bun.file(filePath).text()).toBe(originalContent) + + const updatedSession = await Session.get(sessionID) + expect(updatedSession.revert?.mode).toBe("conversation_and_code") + }, + }) +}) + +test("sequential revert keeps prior conversation mode", async () => { + await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + + const userMsg1 = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg1.id, + sessionID, + type: "text", + text: "First message", + }) + + const assistantMsg1: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg1.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg1) + + const filePath = path.join(tmp.path, "test.txt") + const originalContent = "ORIGINAL" + await Bun.write(filePath, originalContent) + + await Session.updatePart({ + id: Identifier.ascending("part"), + sessionID, + messageID: assistantMsg1.id, + type: "text", + text: "First response", + }) + + const userMsg2 = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg2.id, + sessionID, + type: "text", + text: "Second message", + }) + + const assistantMsg2: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg2.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg2) + + await Session.updatePart({ + id: Identifier.ascending("part"), + sessionID, + messageID: assistantMsg2.id, + type: "text", + text: "Second response", + }) + + const snap = await Snapshot.track() + await Session.updatePart({ + id: Identifier.ascending("part"), + sessionID, + messageID: assistantMsg2.id, + type: "patch", + hash: snap ?? "", + files: ["test.txt"], + }) + + const modifiedContent = "MODIFIED" + await Bun.write(filePath, modifiedContent) + + await SessionRevert.revert({ + sessionID, + messageID: userMsg2.id, + mode: "conversation", + }) + + let updatedSession = await Session.get(sessionID) + expect(updatedSession.revert?.mode).toBe("conversation") + + await SessionRevert.revert({ + sessionID, + messageID: userMsg1.id, + }) + + updatedSession = await Session.get(sessionID) + expect(updatedSession.revert?.mode).toBe("conversation") + expect(await Bun.file(filePath).text()).toBe(modifiedContent) + }, + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2da7c865d770..1fb69f5d6032 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2527,6 +2527,7 @@ export class Session2 extends HeyApiClient { workspace?: string messageID?: string partID?: string + mode?: "conversation" | "conversation_and_code" }, options?: Options, ) { @@ -2540,6 +2541,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "messageID" }, { in: "body", key: "partID" }, + { in: "body", key: "mode" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b6ab684678e9..0620d707abcd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -961,6 +961,7 @@ export type Session = { partID?: string snapshot?: string diff?: string + mode?: "conversation" | "conversation_and_code" } } @@ -1909,6 +1910,7 @@ export type GlobalSession = { partID?: string snapshot?: string diff?: string + mode?: "conversation" | "conversation_and_code" } project: ProjectSummary | null } @@ -4183,6 +4185,7 @@ export type SessionRevertData = { body?: { messageID: string partID?: string + mode?: "conversation" | "conversation_and_code" } path: { sessionID: string From 561e4f83670995b8de9207b0d4cc890ed90285f7 Mon Sep 17 00:00:00 2001 From: Aaryan Rampal <76913929+aaryan-rampal@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:14:53 -0800 Subject: [PATCH 2/5] fix(session): deterministic message ordering to fix revert flakiness Add secondary sort by id to MessageV2.stream() to ensure stable ordering when messages share the same timestamp. This fixes intermittent test failures where assistant patch parts were processed before user messages during revert, causing files to remain in modified state. - Update stream query to orderBy(desc(time_created), desc(id)) - Add regression test with identical user/assistant timestamps --- packages/opencode/src/session/message-v2.ts | 79 ++++++++++++++++ .../opencode/test/session/revert-mode.test.ts | 90 +++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6b64b02319a8..d33faae06f53 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -970,6 +970,85 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( tools, }, ), + } + + export const stream = fn(Identifier.schema("session"), async function* (sessionID) { + const size = 50 + let offset = 0 + while (true) { + const rows = Database.use((db) => + db + .select() + .from(MessageTable) + .where(eq(MessageTable.session_id, sessionID)) + .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) + .limit(size) + .offset(offset) + .all(), + ) + if (rows.length === 0) break + + const ids = rows.map((row) => row.id) + const partsByMessage = new Map() + if (ids.length > 0) { + const partRows = Database.use((db) => + db + .select() + .from(PartTable) + .where(inArray(PartTable.message_id, ids)) + .orderBy(PartTable.message_id, PartTable.id) + .all(), + ) + for (const row of partRows) { + const part = { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + const list = partsByMessage.get(row.message_id) + if (list) list.push(part) + else partsByMessage.set(row.message_id, [part]) + } + } + + for (const row of rows) { + const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info + yield { + info, + parts: partsByMessage.get(row.id) ?? [], + } + } + + offset += rows.length + if (rows.length < size) break + } + }) + + export const parts = fn(Identifier.schema("message"), async (message_id) => { + const rows = Database.use((db) => + db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), + ) + return rows.map( + (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part, + ) + }) + + export const get = fn( + z.object({ + sessionID: Identifier.schema("session"), + messageID: Identifier.schema("message"), + }), + async (input): Promise => { + const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get()) + if (!row) throw new Error(`Message not found: ${input.messageID}`) + const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info + return { + info, + parts: await parts(input.messageID), + } + }, +>>>>>>> 01d5f3f (fix(session): deterministic message ordering to fix revert flakiness) ) }) diff --git a/packages/opencode/test/session/revert-mode.test.ts b/packages/opencode/test/session/revert-mode.test.ts index 93952a846a0d..989cb6fc9627 100644 --- a/packages/opencode/test/session/revert-mode.test.ts +++ b/packages/opencode/test/session/revert-mode.test.ts @@ -124,6 +124,96 @@ test("conversation mode reverts messages but keeps files", async () => { }) }) +test("default revert reverts code when messages share timestamp", async () => { + await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + const now = Date.now() + + const userMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: now, + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID, + type: "text", + text: "Modify test.txt", + }) + + const assistantMsg: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg.id, + time: { + created: now, + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg) + + const filePath = path.join(tmp.path, "test.txt") + await Bun.write(filePath, "ORIGINAL") + + const hash = await Snapshot.track() + expect(hash).toBeTruthy() + + await Bun.write(filePath, "MODIFIED") + + const patch = await Snapshot.patch(hash!) + expect(patch.files.length).toBeGreaterThan(0) + await Session.updatePart({ + id: Identifier.ascending("part"), + sessionID, + messageID: assistantMsg.id, + type: "patch", + hash: patch.hash, + files: patch.files.map((x) => path.relative(tmp.path, x).replaceAll("\\", "/")), + }) + + const messages = await Session.messages({ sessionID }) + expect(messages.map((x) => x.info.id)).toEqual([userMsg.id, assistantMsg.id]) + + await SessionRevert.revert({ + sessionID, + messageID: userMsg.id, + }) + + expect(await Bun.file(filePath).text()).toBe("ORIGINAL") + }, + }) +}) + test("default revert still reverts conversation and code", async () => { await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) await Instance.provide({ From dc188bc24f5a6c7cef2b5eac0475ff36bc915130 Mon Sep 17 00:00:00 2001 From: Aaryan Rampal <76913929+aaryan-rampal@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:34:47 -0800 Subject: [PATCH 3/5] fix(packages): add unique values for the two reverts --- .../src/cli/cmd/tui/routes/session/dialog-message.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index 9ee44e2b84e3..04fa280ff018 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -23,7 +23,7 @@ export function DialogMessage(props: { options={[ { title: "Revert conversation", - value: "session.revert", + value: "session.revert.conversation", description: "undo messages only", onSelect: (dialog) => { const msg = message() @@ -55,7 +55,7 @@ export function DialogMessage(props: { }, { title: "Revert conversation and code", - value: "session.revert", + value: "session.revert.conversation_and_code", description: "undo messages and file changes", onSelect: (dialog) => { const msg = message() From 1bef748c566d29008637ffb1240a042a9239b632 Mon Sep 17 00:00:00 2001 From: Aaryan Rampal <76913929+aaryan-rampal@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:51:52 -0800 Subject: [PATCH 4/5] feat(tests): add (currently) failing test to work on --- .../opencode/test/session/revert-mode.test.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/packages/opencode/test/session/revert-mode.test.ts b/packages/opencode/test/session/revert-mode.test.ts index 989cb6fc9627..8be2a178c6ce 100644 --- a/packages/opencode/test/session/revert-mode.test.ts +++ b/packages/opencode/test/session/revert-mode.test.ts @@ -466,3 +466,163 @@ test("sequential revert keeps prior conversation mode", async () => { }, }) }) + +test("redo to prior conversation-only point restores code state", async () => { + await using tmp = await tmpdir({ git: true, config: { snapshot: true } }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + const file = path.join(tmp.path, "test.txt") + await Bun.write(file, "ORIGINAL") + + const user1 = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: user1.id, + sessionID, + type: "text", + text: "first", + }) + + const ass1: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: user1.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(ass1) + + const hash1 = await Snapshot.track() + expect(hash1).toBeTruthy() + await Bun.write(file, "STEP1") + const patch1 = await Snapshot.patch(hash1!) + await Session.updatePart({ + id: Identifier.ascending("part"), + sessionID, + messageID: ass1.id, + type: "patch", + hash: patch1.hash, + files: patch1.files.map((x) => path.relative(tmp.path, x).replaceAll("\\", "/")), + }) + + const user2 = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: user2.id, + sessionID, + type: "text", + text: "second", + }) + + const ass2: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: user2.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(ass2) + + const hash2 = await Snapshot.track() + expect(hash2).toBeTruthy() + await Bun.write(file, "STEP2") + const patch2 = await Snapshot.patch(hash2!) + await Session.updatePart({ + id: Identifier.ascending("part"), + sessionID, + messageID: ass2.id, + type: "patch", + hash: patch2.hash, + files: patch2.files.map((x) => path.relative(tmp.path, x).replaceAll("\\", "/")), + }) + + await SessionRevert.revert({ + sessionID, + messageID: user2.id, + mode: "conversation", + }) + expect(await Bun.file(file).text()).toBe("STEP2") + + await SessionRevert.revert({ + sessionID, + messageID: user1.id, + mode: "conversation_and_code", + }) + expect(await Bun.file(file).text()).toBe("ORIGINAL") + + await SessionRevert.revert({ + sessionID, + messageID: user2.id, + mode: "conversation_and_code", + }) + + expect(await Bun.file(file).text()).toBe("STEP2") + expect((await Session.get(sessionID)).revert?.mode).toBe("conversation") + }, + }) +}) From ad55fa9d71aaf9dc8b3146b669410d9f5af1cf70 Mon Sep 17 00:00:00 2001 From: Aaryan Rampal <76913929+aaryan-rampal@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:15:26 -0800 Subject: [PATCH 5/5] refactor(cli): extract revert handler to reduce code duplication Address GitHub Copilot review feedback by extracting duplicated onSelect logic into a revert() helper function. Both revert options now share the same implementation, differing only in the mode parameter. --- .../cmd/tui/routes/session/dialog-message.tsx | 88 +++++++------------ 1 file changed, 33 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx index 04fa280ff018..661cd7d27cf4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx @@ -17,6 +17,36 @@ export function DialogMessage(props: { const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID)) const route = useRoute() + function revert(mode: "conversation" | "conversation_and_code") { + return (dialog: { clear: () => void }) => { + const msg = message() + if (!msg) return + + sdk.client.session.revert({ + sessionID: props.sessionID, + messageID: msg.id, + mode, + }) + + if (props.setPrompt) { + const parts = sync.data.part[msg.id] + const promptInfo = parts.reduce( + (agg, part) => { + if (part.type === "text") { + if (!part.synthetic) agg.input += part.text + } + if (part.type === "file") agg.parts.push(part) + return agg + }, + { input: "", parts: [] as PromptInfo["parts"] }, + ) + props.setPrompt(promptInfo) + } + + dialog.clear() + } + } + return ( { - const msg = message() - if (!msg) return - - sdk.client.session.revert({ - sessionID: props.sessionID, - messageID: msg.id, - mode: "conversation", - }) - - if (props.setPrompt) { - const parts = sync.data.part[msg.id] - const promptInfo = parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(part) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ) - props.setPrompt(promptInfo) - } - - dialog.clear() - }, + onSelect: revert("conversation"), }, { title: "Revert conversation and code", value: "session.revert.conversation_and_code", description: "undo messages and file changes", - onSelect: (dialog) => { - const msg = message() - if (!msg) return - - void sdk.client.session.revert({ - sessionID: props.sessionID, - messageID: msg.id, - mode: "conversation_and_code", - }) - - if (props.setPrompt) { - const parts = sync.data.part[msg.id] - const promptInfo = parts.reduce( - (agg, part) => { - if (part.type === "text") { - if (!part.synthetic) agg.input += part.text - } - if (part.type === "file") agg.parts.push(strip(part)) - return agg - }, - { input: "", parts: [] as PromptInfo["parts"] }, - ) - props.setPrompt(promptInfo) - } - - dialog.clear() - }, + onSelect: revert("conversation_and_code"), }, { title: "Copy", @@ -121,7 +99,7 @@ export function DialogMessage(props: { if (part.type === "text") { if (!part.synthetic) agg.input += part.text } - if (part.type === "file") agg.parts.push(part) + if (part.type === "file") agg.parts.push(strip(part)) return agg }, { input: "", parts: [] as PromptInfo["parts"] },