Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
18 changes: 12 additions & 6 deletions packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { compareMessages } from "@/context/revert-page"

const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const messageIndex = (messages: readonly Message[], id: string) => messages.findIndex((message) => message.id === id)
const messageInsertIndex = (messages: readonly Message[], message: Message) => {
const index = messages.findIndex((item) => compareMessages(message, item) < 0)
return index === -1 ? messages.length : index
}

export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
Expand Down Expand Up @@ -188,16 +194,16 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info])
break
}
const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info))
const index = messageIndex(messages, info.id)
if (index !== -1) {
input.setStore("message", info.sessionID, index, reconcile(info))
break
}
input.setStore(
"message",
info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, info)
draft.splice(messageInsertIndex(draft, info), 0, info)
}),
)
break
Expand All @@ -208,8 +214,8 @@ export function applyDirectoryEvent(input: {
produce((draft) => {
const messages = draft.message[props.sessionID]
if (messages) {
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
const index = messageIndex(messages, props.messageID)
if (index !== -1) messages.splice(index, 1)
}
delete draft.part[props.messageID]
}),
Expand Down
147 changes: 147 additions & 0 deletions packages/app/src/context/revert-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { hasVisibleUserBeforeRevert, loadRevertAwareLatestPage } from "./revert-page"

const message = (id: string, role: Message["role"]): Message =>
role === "assistant"
? ({
id,
sessionID: "ses_1",
role: "assistant",
agent: "default",
model: { providerID: "openai", modelID: "gpt-4" },
time: { created: Number(id.slice(2)) },
} as unknown as Message)
: ({
id,
sessionID: "ses_1",
role: "user",
agent: "default",
model: { providerID: "openai", modelID: "gpt-4" },
time: { created: Number(id.slice(2)) },
} as unknown as Message)

const textPart = (id: string, messageID: string): Extract<Part, { type: "text" }> => ({
id,
sessionID: "ses_1",
messageID,
type: "text",
text: id,
})

describe("revert page helpers", () => {
test("detects when the loaded page has no visible user before revert", () => {
expect(hasVisibleUserBeforeRevert([message("m6", "user"), message("m7", "assistant")], "m6")).toBe(false)
expect(hasVisibleUserBeforeRevert([message("m5", "user"), message("m6", "user")], "m6")).toBe(true)
})

test("loads and merges an older boundary window when latest page is fully reverted", async () => {
const olderPart = textPart("p5", "m5")
const boundaryPart = textPart("p6", "m6")

const result = await loadRevertAwareLatestPage({
current: {
session: [message("m6", "user"), message("m7", "assistant"), message("m8", "user")],
part: [
{ id: "m6", part: [boundaryPart] },
{ id: "m7", part: [] },
{ id: "m8", part: [] },
],
cursor: undefined,
complete: true,
},
revertMessageID: "m6",
fetchMessage: async () => ({ info: message("m6", "user"), parts: [boundaryPart], cursor: "boundary" }),
fetchPage: async (before) => {
expect(before).toBe("boundary")
return {
session: [message("m4", "assistant"), message("m5", "user")],
part: [
{ id: "m4", part: [] },
{ id: "m5", part: [olderPart] },
],
cursor: "older",
complete: false,
}
},
})

expect(result.session.map((item) => item.id)).toEqual(["m4", "m5", "m6", "m7", "m8"])
expect(result.part.find((item) => item.id === "m5")?.part).toEqual([olderPart])
expect(result.part.find((item) => item.id === "m6")?.part).toEqual([boundaryPart])
expect(result.cursor).toBe("older")
expect(result.complete).toBe(false)
})

test("keeps loading older pages until a visible user exists before revert", async () => {
const boundaryPart = textPart("p6", "m6")
const olderPart = textPart("p3", "m3")
let call = 0

const result = await loadRevertAwareLatestPage({
current: {
session: [message("m6", "user"), message("m7", "assistant"), message("m8", "user")],
part: [
{ id: "m6", part: [boundaryPart] },
{ id: "m7", part: [] },
{ id: "m8", part: [] },
],
cursor: undefined,
complete: true,
},
revertMessageID: "m6",
fetchMessage: async () => ({ info: message("m6", "user"), parts: [boundaryPart], cursor: "boundary" }),
fetchPage: async () => {
call += 1
if (call === 1) {
return {
session: [message("m4", "assistant"), message("m5", "assistant")],
part: [
{ id: "m4", part: [] },
{ id: "m5", part: [] },
],
cursor: "older-2",
complete: false,
}
}
return {
session: [message("m3", "user")],
part: [{ id: "m3", part: [olderPart] }],
cursor: undefined,
complete: true,
}
},
})

expect(call).toBe(2)
expect(result.session.map((item) => item.id)).toEqual(["m3", "m4", "m5", "m6", "m7", "m8"])
expect(result.part.find((item) => item.id === "m3")?.part).toEqual([olderPart])
expect(result.cursor).toBeUndefined()
expect(result.complete).toBe(true)
})

test("marks stale revert boundaries for clearing when the boundary message is missing", async () => {
const result = await loadRevertAwareLatestPage({
current: {
session: [message("m7", "assistant"), message("m8", "user")],
part: [
{ id: "m7", part: [] },
{ id: "m8", part: [] },
],
cursor: undefined,
complete: true,
},
revertMessageID: "m6",
fetchMessage: async () => undefined,
fetchPage: async () => ({
session: [],
part: [],
cursor: undefined,
complete: true,
}),
})

expect(result.clearedRevert).toBe(true)
expect(result.session.map((item) => item.id)).toEqual(["m7", "m8"])
})
})
77 changes: 77 additions & 0 deletions packages/app/src/context/revert-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"

type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
clearedRevert?: boolean
}

type MessageWithParts = {
info: Message
parts: Part[]
cursor?: string
}

const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
export const compareMessages = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
export const messageBefore = (message: Message, boundary: Message) => compareMessages(message, boundary) < 0

const sortParts = (parts: Part[]) => parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))

export function hasVisibleUserBeforeRevert(messages: Message[], revertMessageID?: string, boundary?: Message) {
if (!revertMessageID) return true
const revert = boundary ?? messages.find((message) => message.id === revertMessageID)
if (!revert) return messages.some((message) => message.role === "user" && message.id < revertMessageID)
return messages.some((message) => message.role === "user" && messageBefore(message, revert))
}

function mergeMessages(current: Message[], older: Message[], boundary: Message) {
const merged = new Map(current.filter((message) => !!message?.id).map((message) => [message.id, message] as const))
for (const message of older) {
if (!message?.id) continue
merged.set(message.id, message)
}
merged.set(boundary.id, boundary)
return [...merged.values()].sort(compareMessages)
}

function mergeParts(current: MessagePage["part"], older: MessagePage["part"], boundary: MessageWithParts) {
const merged = new Map(current.filter((item) => !!item?.id).map((item) => [item.id, sortParts(item.part)] as const))
for (const item of older) {
if (!item?.id) continue
merged.set(item.id, sortParts(item.part))
}
merged.set(boundary.info.id, sortParts(boundary.parts))
return [...merged.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part }))
}

export async function loadRevertAwareLatestPage(input: {
current: MessagePage
revertMessageID?: string
fetchMessage: (messageID: string) => Promise<MessageWithParts | undefined>
fetchPage: (before: string) => Promise<MessagePage>
}) {
if (!input.revertMessageID) return input.current

const boundary = await input.fetchMessage(input.revertMessageID)
if (!boundary) return { ...input.current, clearedRevert: true }
if (hasVisibleUserBeforeRevert(input.current.session, input.revertMessageID, boundary.info)) return input.current
if (!boundary.cursor) return input.current

let older = await input.fetchPage(boundary.cursor)
let session = mergeMessages(input.current.session, older.session, boundary.info)
let part = mergeParts(input.current.part, older.part, boundary)
while (!hasVisibleUserBeforeRevert(session, input.revertMessageID) && older.cursor) {
older = await input.fetchPage(older.cursor)
session = mergeMessages(session, older.session, boundary.info)
part = mergeParts(part, older.part, boundary)
}
return {
session,
part,
cursor: older.cursor,
complete: older.complete,
}
}
Loading
Loading