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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { sortMessages } from "@opencode-ai/util/message"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
Expand Down Expand Up @@ -100,7 +101,7 @@ export function SessionContextTab() {
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
return sortMessages((sync.data.message[id] ?? []) as Message[])
},
emptyMessages,
{ equals: same },
Expand Down
13 changes: 7 additions & 6 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,8 @@ export default function Page() {
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
const idx = userMessages().findIndex((m) => m.id === revert)
return idx >= 0 ? userMessages().slice(0, idx) : userMessages()
},
emptyUserMessages,
{
Expand Down Expand Up @@ -569,7 +570,7 @@ export default function Page() {
)
return
}
const at = list.findIndex((item) => item.id > next.id)
const at = list.findIndex((item) => item.id.localeCompare(next.id) > 0)
if (at >= 0) {
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
Expand Down Expand Up @@ -1245,7 +1246,8 @@ export default function Page() {
const sessionID = params.id
if (!sessionID || ui.restoring) return

const next = userMessages().find((item) => item.id > id)
const idx = userMessages().findIndex((m) => m.id === id)
const next = idx >= 0 ? userMessages()[idx + 1] : undefined
setUi("restoring", id)

const task = !next
Expand Down Expand Up @@ -1273,9 +1275,8 @@ export default function Page() {
const rolled = createMemo(() => {
const id = revertMessageID()
if (!id) return []
return userMessages()
.filter((item) => item.id >= id)
.map((item) => ({ id: item.id, text: line(item.id) }))
const idx = userMessages().findIndex((m) => m.id === id)
return (idx >= 0 ? userMessages().slice(idx) : []).map((item) => ({ id: item.id, text: line(item.id) }))
})

const actions = { fork, revert }
Expand Down
14 changes: 8 additions & 6 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { sortMessages } from "@opencode-ai/util/message"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
Expand Down Expand Up @@ -227,7 +227,7 @@ export function MessageTimeline(props: {
const sessionMessages = createMemo(() => {
const id = sessionID()
if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages
return sortMessages(sync.data.message[id] ?? emptyMessages)
})
const pending = createMemo(() =>
sessionMessages().findLast(
Expand Down Expand Up @@ -277,8 +277,7 @@ export function MessageTimeline(props: {
const parentID = pending()?.parentID
if (parentID) {
const messages = sessionMessages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
const message = messages.find((item) => item.id === parentID)
if (message && message.role === "user") return message.id
}

Expand Down Expand Up @@ -755,8 +754,11 @@ export function MessageTimeline(props: {
const queued = createMemo(() => {
if (active()) return false
const activeID = activeMessageID()
if (activeID) return messageID > activeID
return false
if (!activeID) return false
const ids = rendered()
const activeIdx = ids.indexOf(activeID)
if (activeIdx === -1) return false
return ids.indexOf(messageID) > activeIdx
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
Expand Down
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 @@ -18,6 +18,7 @@ import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
import { showToast } from "@opencode-ai/ui/toast"
import { findLast } from "@opencode-ai/util/array"
import { sortMessages } from "@opencode-ai/util/message"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
Expand Down Expand Up @@ -54,7 +55,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {

const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messages = createMemo(() => sortMessages(params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,11 @@ export namespace Session {
})
const msgs = await messages({ sessionID: input.sessionID })
const idMap = new Map<string, MessageID>()
const cutoff = input.messageID ? msgs.findIndex((msg) => msg.info.id === input.messageID) : -1

for (const msg of msgs) {
if (input.messageID && msg.info.id >= input.messageID) break
for (let i = 0; i < msgs.length; i++) {
if (cutoff >= 0 && i >= cutoff) break
const msg = msgs[i]
const newID = MessageID.ascending()
idMap.set(msg.info.id, newID)

Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,14 @@ export namespace MessageV2 {
) {
continue
}
// Skip incomplete assistant messages (no finish, no error, and no meaningful parts)
if (
!msg.info.finish &&
!msg.info.error &&
!msg.parts.some((part) => part.type !== "step-start")
) {
continue
}
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
Expand Down
40 changes: 30 additions & 10 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,16 @@ export namespace SessionPrompt {
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))

let lastUser: MessageV2.User | undefined
let lastAssistant: MessageV2.Assistant | undefined
let lastFinished: MessageV2.Assistant | undefined
let finishedIdx = -1
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) {
lastFinished = msg.info as MessageV2.Assistant
finishedIdx = i
}
if (lastUser && lastFinished) break
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
if (task && !lastFinished) {
Expand All @@ -317,11 +318,7 @@ export namespace SessionPrompt {
}

if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
if (
lastAssistant?.finish &&
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
if (shouldExitLoop(lastUser, lastFinished)) {
log.info("exiting loop", { sessionID })
break
}
Expand Down Expand Up @@ -631,8 +628,9 @@ export namespace SessionPrompt {

// Ephemerally wrap queued user messages with a reminder to stay on track
if (step > 1 && lastFinished) {
for (const msg of msgs) {
if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
if (!shouldWrapSystemReminder(msg.info, i, lastFinished, finishedIdx)) continue
for (const part of msg.parts) {
if (part.type !== "text" || part.ignored || part.synthetic) continue
if (!part.text.trim()) continue
Expand Down Expand Up @@ -1966,4 +1964,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return Session.setTitle({ sessionID: input.session.id, title })
}
}

export function shouldExitLoop(
lastUser: MessageV2.User | undefined,
lastAssistant: MessageV2.Assistant | undefined,
): boolean {
if (!lastUser) return false
if (!lastAssistant?.finish) return false
if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false
if (!lastAssistant.parentID) return true
return lastAssistant.parentID === lastUser.id
}

export function shouldWrapSystemReminder(
msg: MessageV2.User | MessageV2.Assistant,
idx: number,
lastFinished: MessageV2.Assistant | undefined,
finishedIdx: number,
): boolean {
if (msg.role !== "user") return false
if (!lastFinished) return false
return idx > finishedIdx
}
}
35 changes: 21 additions & 14 deletions packages/opencode/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export namespace SessionRevert {
revert.snapshot = session.revert?.snapshot ?? (await Snapshot.track())
await Snapshot.revert(patches)
if (revert.snapshot) revert.diff = await Snapshot.diff(revert.snapshot)
const rangeMessages = all.filter((msg) => msg.info.id >= revert!.messageID)
const idx = all.findIndex((msg) => msg.info.id === revert!.messageID)
const rangeMessages = idx >= 0 ? all.slice(idx) : all
const diffs = await SessionSummary.computeDiff({ messages: rangeMessages })
await Storage.write(["session_diff", input.sessionID], diffs)
Bus.publish(Session.Event.Diff, {
Expand Down Expand Up @@ -96,21 +97,27 @@ export namespace SessionRevert {
const preserve = [] as MessageV2.WithParts[]
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) {
preserve.push(msg)
continue
}
if (msg.info.id > messageID) {
const idx = msgs.findIndex((msg) => msg.info.id === messageID)
if (idx < 0) {
preserve.push(...msgs)
} else {
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]!
if (i < idx) {
preserve.push(msg)
continue
}
if (i > idx) {
remove.push(msg)
continue
}
if (session.revert.partID) {
preserve.push(msg)
target = msg
continue
}
remove.push(msg)
continue
}
if (session.revert.partID) {
preserve.push(msg)
target = msg
continue
}
remove.push(msg)
}
for (const msg of remove) {
Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
Expand Down
84 changes: 84 additions & 0 deletions packages/opencode/test/session/fixtures/skewed-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Identifier } from "../../../src/id/id"
import { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, SessionID } from "../../../src/session/schema"
import { ModelID, ProviderID } from "../../../src/provider/schema"

export function makeUser(
id: MessageID,
opts?: Partial<MessageV2.User>,
): MessageV2.User {
return {
id,
sessionID: SessionID.make("test-session"),
role: "user",
time: { created: Date.now() },
agent: "default",
model: {
providerID: ProviderID.openai,
modelID: ModelID.make("gpt-4"),
},
...opts,
}
}

export function makeAssistant(
id: MessageID,
parentID: MessageID,
opts?: Partial<MessageV2.Assistant>,
): MessageV2.Assistant {
return {
id,
sessionID: SessionID.make("test-session"),
role: "assistant",
parentID,
time: { created: Date.now() },
modelID: ModelID.make("gpt-4"),
providerID: ProviderID.openai,
mode: "default",
agent: "default",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "stop",
...opts,
}
}

export function aheadPair(): {
user: MessageV2.User
assistant: MessageV2.Assistant
} {
const now = Date.now()
const userID = MessageID.make(Identifier.create("message", false, now + 60_000))
const assistantID = MessageID.make(Identifier.create("message", false, now))

return {
user: makeUser(userID),
assistant: makeAssistant(assistantID, userID),
}
}

export function behindPair(): {
user: MessageV2.User
assistant: MessageV2.Assistant
} {
const now = Date.now()
const userID = MessageID.make(Identifier.create("message", false, now - 60_000))
const assistantID = MessageID.make(Identifier.create("message", false, now))

return {
user: makeUser(userID),
assistant: makeAssistant(assistantID, userID),
}
}
Loading
Loading