Skip to content
Draft
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
54 changes: 27 additions & 27 deletions bun.lock

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

2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.10",
"version": "1.17.11",
"description": "",
"type": "module",
"exports": {
Expand Down
47 changes: 45 additions & 2 deletions packages/app/src/context/server-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import { describe, expect, mock, test } from "bun:test"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"

const session = (id: string, parentID?: string): Session => ({
Expand Down Expand Up @@ -55,6 +55,49 @@ describe("server session", () => {
expect(ctx.store.data.message.root).toEqual([])
})

test("retries transient message hydration failures", async () => {
const message: Message = {
id: "message",
sessionID: "root",
role: "user",
time: { created: 1 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "anthropic", modelID: "claude" },
}
const part: Extract<Part, { type: "text" }> = {
id: "part",
sessionID: "root",
messageID: message.id,
type: "text",
text: "hello",
}
const messages = mock(async () => {
if (messages.mock.calls.length === 1) {
throw new Error("GET /session/root/message -> 503 Service Unavailable", {
cause: { status: 503 },
})
}

return {
data: [{ info: message, parts: [part] }],
response: { headers: new Headers() },
}
})
const store = createServerSession({
session: {
get: mock(async () => ({ data: session("root") })),
messages,
},
} as unknown as OpencodeClient)

await store.sync("root")

expect(messages).toHaveBeenCalledTimes(2)
expect(store.data.message.root?.map((message) => message.id)).toEqual(["message"])
expect(store.data.part.message?.map((part) => part.id)).toEqual(["part"])
})

test("applies events without a directory store", () => {
const ctx = setup({})
ctx.store.apply({ type: "session.created", properties: { info: session("root") } })
Expand Down
33 changes: 30 additions & 3 deletions packages/app/src/context/server-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 2
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const retryableMessageLoadStatuses = new Set([408, 409, 425, 429, 500, 502, 503, 504])

type OptimisticItem = {
message: Message
Expand Down Expand Up @@ -75,6 +76,24 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
}

function isRetryableMessageLoadError(error: unknown) {
if (error instanceof Error) {
const status = (error.cause as { status?: unknown } | undefined)?.status
if (typeof status === "number" && retryableMessageLoadStatuses.has(status)) return true
return [
"load failed",
"network connection was lost",
"network request failed",
"failed to fetch",
"econnreset",
"econnrefused",
"etimedout",
"socket hang up",
].some((message) => error.message.toLowerCase().includes(message))
}
return false
}

export function createServerSession(client: OpencodeClient) {
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
Expand All @@ -92,6 +111,7 @@ export function createServerSession(client: OpencodeClient) {
})
const requests = new Map<string, Promise<Session>>()
const inflight = new Map<string, Promise<void>>()
const messageLoads = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
Expand Down Expand Up @@ -202,6 +222,7 @@ export function createServerSession(client: OpencodeClient) {
clearOptimistic(sessionID)
requests.delete(sessionID)
inflight.delete(sessionID)
messageLoads.delete(sessionID)
inflightDiff.delete(sessionID)
inflightTodo.delete(sessionID)
})
Expand Down Expand Up @@ -248,7 +269,9 @@ export function createServerSession(client: OpencodeClient) {
)

const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
const response = await retry(() => client.session.messages({ sessionID, limit, before }), {
retryIf: isRetryableMessageLoadError,
})
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return {
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
Expand All @@ -262,10 +285,11 @@ export function createServerSession(client: OpencodeClient) {
}

const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return
const pending = messageLoads.get(sessionID)
if (pending) return pending
const generation = generations.get(sessionID) ?? 0
setMeta("loading", sessionID, true)
await fetchMessages(sessionID, limit, before)
const request = fetchMessages(sessionID, limit, before)
.then((page) => {
if ((generations.get(sessionID) ?? 0) !== generation) return
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
Expand All @@ -284,8 +308,11 @@ export function createServerSession(client: OpencodeClient) {
})
})
.finally(() => {
if (messageLoads.get(sessionID) === request) messageLoads.delete(sessionID)
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
})
messageLoads.set(sessionID, request)
await request
}

const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
Expand Down
Loading