Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/app/src/pages/session/use-session-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const mode = info()?.revert?.mode
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id, mode })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,40 @@ export function DialogMessage(props: {
title="Message Actions"
options={[
{
title: "Revert",
value: "session.revert",
title: "Revert conversation",
value: "session.revert.conversation",
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.conversation_and_code",
description: "undo messages and file changes",
onSelect: (dialog) => {
const msg = message()
Expand All @@ -31,6 +63,7 @@ export function DialogMessage(props: {
sdk.client.session.revert({
sessionID: props.sessionID,
messageID: msg.id,
mode: "conversation_and_code",
})

if (props.setPrompt) {
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -543,9 +543,11 @@ export function Session() {
prompt.set({ input: "", parts: [] })
return
}
const mode = session()?.revert?.mode
sdk.client.session.revert({
sessionID: route.sessionID,
messageID: message.id,
mode,
})
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export namespace Session {
partID: z.string().optional(),
snapshot: z.string().optional(),
diff: z.string().optional(),
mode: z.enum(["conversation", "conversation_and_code"]).optional(),
})
.optional(),
})
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ export namespace MessageV2 {
.select()
.from(MessageTable)
.where(eq(MessageTable.session_id, sessionID))
.orderBy(desc(MessageTable.time_created))
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))

@aaryan-rampal aaryan-rampal Mar 7, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we added secondary sorting by MessageTable.id:

Session revert tests failed intermittently because MessageV2.stream() ordered messages only by time_created descending. When user and assistant messages had identical timestamps (in test cases that capture Date.now() once) the database could return rows in either order. If the assistant message appeared first, SessionRevert.revert() never encountered assistant patch parts after the user boundary, so files stayed in MODIFIED state instead of reverting to ORIGINAL.

Adding desc(MessageTable.id) as a tie-breaker ensures deterministic ordering: messages with equal timestamps sort by ID, preserving the expected [user, assistant] sequence regardless of row-level arbitration.

Is this strictly necessary or will show up in daily usage? Maybe not, and maybe it was due to the fact that I really shouldn't be doing Date.now() once in my tests. But I did, and I found this, and maybe this will help in the future?

.limit(size)
.offset(offset)
.all(),
Expand Down
17 changes: 16 additions & 1 deletion packages/opencode/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export namespace SessionRevert {
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
partID: Identifier.schema("part").optional(),
mode: z.enum(["conversation", "conversation_and_code"]).optional(),
})
export type RevertInput = z.infer<typeof RevertInput>

Expand All @@ -27,6 +28,7 @@ export namespace SessionRevert {
let lastUser: MessageV2.User | undefined
const session = await Session.get(input.sessionID)

const mode = input.mode ?? session.revert?.mode ?? "conversation_and_code"
let revert: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
for (const msg of all) {
Expand Down Expand Up @@ -56,6 +58,19 @@ export namespace SessionRevert {

if (revert) {
const session = await Session.get(input.sessionID)

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the revert function, when mode === "conversation" the function returns early at line 72, but the const session = await Session.get(input.sessionID) at line 60 has already fetched the session from the database unnecessarily in that path. This session is only used at line 74 (revert.snapshot = session.revert?.snapshot ...), which only runs for the conversation_and_code branch. The database fetch at line 60 is a redundant call when mode === "conversation", since it fetches a fresh session that is immediately discarded.

Suggested change
const session = await Session.get(input.sessionID)

Copilot uses AI. Check for mistakes.
if (mode === "conversation") {
const rangeMessages = all.filter((msg) => msg.info.id >= revert!.messageID)
const diffs = await SessionSummary.computeDiff({ messages: rangeMessages })
return Session.setRevert({
sessionID: input.sessionID,
revert: { ...revert, mode },
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
})
}
revert.snapshot = session.revert?.snapshot ?? (await Snapshot.track())
await Snapshot.revert(patches)
if (revert.snapshot) revert.diff = await Snapshot.diff(revert.snapshot)
Expand All @@ -68,7 +83,7 @@ export namespace SessionRevert {
})
return Session.setRevert({
sessionID: input.sessionID,
revert,
revert: { ...revert, mode },
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/session/session.sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ export const SessionTable = sqliteTable(
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(),
revert: text({ mode: "json" }).$type<{
messageID: string
partID?: string
snapshot?: string
diff?: string
mode?: "conversation" | "conversation_and_code"
}>(),
permission: text({ mode: "json" }).$type<PermissionNext.Ruleset>(),
...Timestamps,
time_compacting: integer(),
Expand Down
Loading
Loading