Download packages/app/src/context/server-session.ts from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 57.8 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/context/server-session.ts
- Command line
-
hf download hf://SaylorTwift/opencode/packages/app/src/context/server-session.ts
-
curl -L -o server-session.ts https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/context/server-session.ts
57.8 kB
| import { Binary } from "@opencode-ai/core/util/binary" | |
| import { retry } from "@opencode-ai/core/util/retry" | |
| import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" | |
| import type { | |
| Message, | |
| OpencodeClient, | |
| Part, | |
| PermissionRequest, | |
| QuestionRequest, | |
| Session, | |
| SessionStatus, | |
| Todo, | |
| } from "@opencode-ai/sdk/v2/client" | |
| import type { FileDiffInfo } from "@opencode-ai/client/promise" | |
| import { batch } from "solid-js" | |
| import { createStore, produce, reconcile } from "solid-js/store" | |
| import { message as cleanMessage } from "@/utils/diffs" | |
| import { sessionNotFoundError } from "@/utils/server-errors" | |
| import { rootSession } from "@/utils/session-route" | |
| import { normalizeSessionInfo } from "@/utils/session" | |
| import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message" | |
| import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" | |
| import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" | |
| import type { ServerApi } from "@/utils/server" | |
| type MessageApi = ServerApi["message"] | |
| const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) | |
| const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) | |
| const initialMessagePageSize = 20 | |
| const historyMessagePageSize = 200 | |
| const sessionInfoLimit = 2_048 | |
| const emptyIDs: ReadonlySet<string> = new Set() | |
| function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { | |
| const boundary = source.find( | |
| (message) => | |
| message.type === "user" || | |
| message.type === "shell" || | |
| message.type === "assistant" || | |
| (message.type === "synthetic" && message.description?.trim()), | |
| ) | |
| return boundary?.type === "assistant" | |
| } | |
| type OptimisticItem = { | |
| message: Message | |
| parts: Part[] | |
| confirmedParts?: Part[] | |
| confirmedMessage?: boolean | |
| } | |
| type MessagePage = { | |
| session: Message[] | |
| part: { id: string; part: Part[] }[] | |
| source?: SessionMessageInfo[] | |
| sourceMode?: "latest" | "older" | |
| projectSource?: boolean | |
| cursor?: string | |
| complete: boolean | |
| } | |
| function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { | |
| return items | |
| .slice() | |
| .sort((a, b) => compareMessages(a.info, b.info)) | |
| .map((item) => { | |
| if (item.info.role === "user") { | |
| return { | |
| id: item.info.id, | |
| type: "user" as const, | |
| text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), | |
| time: item.info.time, | |
| } | |
| } | |
| return { | |
| id: item.info.id, | |
| type: "assistant" as const, | |
| agent: item.info.agent ?? item.info.mode, | |
| model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, | |
| content: [], | |
| time: item.info.time, | |
| } | |
| }) | |
| } | |
| // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. | |
| type MessageLoadState = { | |
| touchedMessages: Set<string> | |
| removedMessages: Set<string> | |
| retainedMessages: Set<string> | |
| touchedParts: Map<string, Set<string>> | |
| deltaParts: Map<string, Set<string>> | |
| carriedDeltaParts: Map<string, Set<string>> | |
| removedParts: Map<string, Set<string>> | |
| optimisticParts: Map<string, Set<string>> | |
| orphanParents: Set<string> | |
| clearedMessageParts: Set<string> | |
| touchedSource: Set<string> | |
| } | |
| type MessageLoadBaseline = Pick< | |
| MessageLoadState, | |
| "touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts" | |
| > | |
| function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { | |
| if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] } | |
| const session = [...page.session] | |
| const part = new Map(page.part.map((item) => [item.id, item.part])) | |
| const observed: { messageID: string; parts: Part[] }[] = [] | |
| for (const item of items) { | |
| const result = Binary.search(session, messageKey(item.message), messageKey) | |
| const found = result.found | |
| if (!found) session.splice(result.index, 0, item.message) | |
| const current = part.get(item.message.id) | |
| const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : [] | |
| if (found) observed.push({ messageID: item.message.id, parts: confirmed }) | |
| part.set( | |
| item.message.id, | |
| merge( | |
| found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), | |
| item.parts.filter((part) => !confirmed.includes(part)), | |
| ), | |
| ) | |
| } | |
| return { | |
| ...page, | |
| session, | |
| part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })), | |
| observed, | |
| } | |
| } | |
| function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) { | |
| const pending = map.get(key) | |
| if (pending) return pending | |
| const promise = task().finally(() => { | |
| if (map.get(key) === promise) map.delete(key) | |
| }) | |
| map.set(key, promise) | |
| return promise | |
| } | |
| function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) { | |
| const items = new Map(a.map((item) => [item.id, item] as const)) | |
| for (const item of b) items.set(item.id, item) | |
| return [...items.values()].sort((x, y) => cmp(x.id, y.id)) | |
| } | |
| function reconcileFetched<T extends { id: string }>( | |
| fetched: T[], | |
| current: readonly T[], | |
| options: { | |
| touched?: ReadonlySet<string> | |
| retained?: ReadonlySet<string> | |
| removed?: ReadonlySet<string> | |
| preserveUnfetched?: boolean | ((item: T) => boolean) | |
| compare?: (a: T, b: T) => number | |
| } = {}, | |
| ) { | |
| const result = new Map(fetched.map((item) => [item.id, item])) | |
| const live = new Map(current.map((item) => [item.id, item])) | |
| if (options.preserveUnfetched) { | |
| for (const item of current) { | |
| if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item))) | |
| result.set(item.id, item) | |
| } | |
| } | |
| for (const id of options.retained ?? emptyIDs) { | |
| if (result.has(id)) continue | |
| const item = live.get(id) | |
| if (item) result.set(id, item) | |
| } | |
| // Events observed while the request is pending are the freshest client state for those identities. | |
| for (const id of options.touched ?? emptyIDs) { | |
| const item = live.get(id) | |
| if (item) result.set(id, item) | |
| if (!item) result.delete(id) | |
| } | |
| for (const id of options.removed ?? emptyIDs) result.delete(id) | |
| const items = [...result.values()] | |
| return options.compare ? items.sort(options.compare) : items | |
| } | |
| type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } | |
| export function createServerSession( | |
| client: OpencodeClient, | |
| sessionApiOrOptions?: SessionApi | ServerSessionOptions, | |
| messageApi?: MessageApi, | |
| currentOptions?: ServerSessionOptions, | |
| ) { | |
| const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined | |
| const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined) | |
| const [data, setData] = createStore({ | |
| info: {} as Record<string, Session | undefined>, | |
| session_status: {} as Record<string, SessionStatus>, | |
| session_diff: {} as Record<string, FileDiffInfo[]>, | |
| todo: {} as Record<string, Todo[]>, | |
| permission: {} as Record<string, PermissionRequest[]>, | |
| question: {} as Record<string, QuestionRequest[]>, | |
| message: {} as Record<string, Message[]>, | |
| session_message: {} as Record<string, SessionMessageInfo[]>, | |
| part: {} as Record<string, Part[]>, | |
| part_text_accum_delta: {} as Record<string, string>, | |
| session_working(id: string) { | |
| return (this.session_status[id]?.type ?? "idle") !== "idle" | |
| }, | |
| }) | |
| const requests = new Map<string, Promise<Session>>() | |
| const inflight = new Map<string, Promise<void>>() | |
| const inflightTodo = new Map<string, Promise<void>>() | |
| const optimistic = new Map<string, Map<string, OptimisticItem>>() | |
| const v2 = createV2SessionReducer() | |
| const messageLoads = new Map<string, MessageLoadState>() | |
| const pendingParts = new Map<string, Map<string, Set<string>>>() | |
| const orphanParts = new Map<string, Set<string>>() | |
| const removedMessages = new Map<string, Set<string>>() | |
| const deltaBases = new Map<string, { base: string; sessionID: string }>() | |
| const deleteMessageParts = ( | |
| cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> }, | |
| messageID: string, | |
| ) => { | |
| for (const part of cache.part[messageID] ?? []) { | |
| delete cache.part_text_accum_delta[part.id] | |
| deltaBases.delete(part.id) | |
| } | |
| delete cache.part[messageID] | |
| } | |
| const seen = new Set<string>() | |
| const infoSeen = new Set<string>() | |
| const pinned = new Map<string, number>() | |
| const generations = new Map<string, object>() | |
| const generation = (sessionID: string) => { | |
| const current = generations.get(sessionID) | |
| if (current) return current | |
| const created = {} | |
| generations.set(sessionID, created) | |
| return created | |
| } | |
| const [meta, setMeta] = createStore({ | |
| limit: {} as Record<string, number | undefined>, | |
| cursor: {} as Record<string, string | undefined>, | |
| complete: {} as Record<string, boolean | undefined>, | |
| loading: {} as Record<string, boolean | undefined>, | |
| at: {} as Record<string, number | undefined>, | |
| }) | |
| const indexLegacyMessage = (message: Message) => { | |
| const current = data.session_message[message.sessionID] ?? [] | |
| if (current.some((item) => item.id === message.id)) return | |
| setData( | |
| "session_message", | |
| message.sessionID, | |
| reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]), | |
| ) | |
| } | |
| const remember = (session: Session) => { | |
| setData("info", session.id, reconcile(session)) | |
| infoSeen.delete(session.id) | |
| infoSeen.add(session.id) | |
| if (infoSeen.size > sessionInfoLimit) { | |
| const preserve = new Set([ | |
| ...pinned.keys(), | |
| ...requests.keys(), | |
| ...inflight.keys(), | |
| ...inflightTodo.keys(), | |
| ...messageLoads.keys(), | |
| ...optimistic.keys(), | |
| ...Object.entries(data.permission) | |
| .filter(([, items]) => items.length > 0) | |
| .map(([sessionID]) => sessionID), | |
| ...Object.entries(data.question) | |
| .filter(([, items]) => items.length > 0) | |
| .map(([sessionID]) => sessionID), | |
| ...Object.entries(data.session_status) | |
| .filter(([, status]) => status.type !== "idle") | |
| .map(([sessionID]) => sessionID), | |
| ]) | |
| for (const sessionID of preserve) { | |
| let current = data.info[sessionID] | |
| while (current) { | |
| preserve.add(current.id) | |
| current = current.parentID ? data.info[current.parentID] : undefined | |
| } | |
| } | |
| const stale: string[] = [] | |
| for (const sessionID of infoSeen) { | |
| if (infoSeen.size - stale.length <= sessionInfoLimit) break | |
| if (!preserve.has(sessionID)) stale.push(sessionID) | |
| } | |
| stale.forEach((sessionID) => infoSeen.delete(sessionID)) | |
| stale.forEach((sessionID) => generations.delete(sessionID)) | |
| setData( | |
| "info", | |
| produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])), | |
| ) | |
| } | |
| return session | |
| } | |
| const resolve = (sessionID: string, options?: { force?: boolean }) => { | |
| const cached = data.info[sessionID] | |
| if (cached && !options?.force) return Promise.resolve(cached) | |
| const pending = requests.get(sessionID) | |
| if (pending) return pending | |
| const active = generation(sessionID) | |
| const request = sessionApi | |
| ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) | |
| : client.session.get({ sessionID }).then((result) => { | |
| if (!result.data) throw sessionNotFoundError(sessionID) | |
| return result.data | |
| }) | |
| const resolved = request.then((result) => { | |
| if (generations.get(sessionID) !== active) return result | |
| return remember(result) | |
| }) | |
| requests.set(sessionID, resolved) | |
| const cleanup = () => { | |
| if (requests.get(sessionID) === resolved) requests.delete(sessionID) | |
| if ( | |
| generations.get(sessionID) === active && | |
| !data.info[sessionID] && | |
| !requests.has(sessionID) && | |
| !messageLoads.has(sessionID) && | |
| !inflight.has(sessionID) && | |
| !inflightTodo.has(sessionID) | |
| ) | |
| generations.delete(sessionID) | |
| } | |
| void resolved.then(cleanup, cleanup) | |
| return resolved | |
| } | |
| const peekLineage = (sessionID: string) => { | |
| const session = data.info[sessionID] | |
| if (!session) return | |
| const seen = new Set([session.id]) | |
| let root = session | |
| while (root.parentID) { | |
| if (seen.has(root.parentID)) throw new Error(`Session parent cycle: ${root.parentID}`) | |
| seen.add(root.parentID) | |
| const parent = data.info[root.parentID] | |
| if (!parent) return | |
| root = parent | |
| } | |
| return { session, root } | |
| } | |
| const clearOptimistic = (sessionID: string, messageID?: string) => { | |
| if (!messageID) { | |
| optimistic.delete(sessionID) | |
| return | |
| } | |
| const items = optimistic.get(sessionID) | |
| if (!items) return | |
| items.delete(messageID) | |
| if (items.size === 0) optimistic.delete(sessionID) | |
| } | |
| const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => { | |
| const items = optimistic.get(sessionID) | |
| const item = items?.get(messageID) | |
| if (!items || !item) return | |
| const parts = item.parts.filter((part) => part.id !== partID) | |
| const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID) | |
| if (parts.length === 0) { | |
| clearOptimistic(sessionID, messageID) | |
| return | |
| } | |
| items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true }) | |
| } | |
| const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => { | |
| const items = optimistic.get(sessionID) | |
| const item = items?.get(messageID) | |
| if (!items || !item) return | |
| const parts = item.parts.filter((value) => value.id !== part.id) | |
| if (parts.length === 0) { | |
| clearOptimistic(sessionID, messageID) | |
| return | |
| } | |
| items.set(messageID, { | |
| ...item, | |
| parts, | |
| confirmedParts: merge(item.confirmedParts ?? [], [part]), | |
| confirmedMessage: true, | |
| }) | |
| } | |
| const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => { | |
| const items = optimistic.get(sessionID) | |
| const item = items?.get(messageID) | |
| if (!items || !item) return | |
| const confirmed = new Set(confirmedParts.map((part) => part.id)) | |
| const parts = item.parts.filter((part) => !confirmed.has(part.id)) | |
| if (parts.length === 0) { | |
| clearOptimistic(sessionID, messageID) | |
| return | |
| } | |
| items.set(messageID, { | |
| ...item, | |
| parts, | |
| confirmedParts: merge(item.confirmedParts ?? [], confirmedParts), | |
| confirmedMessage: true, | |
| }) | |
| } | |
| const trackPartChange = (sessionID: string, messageID: string, partID: string) => { | |
| const load = messageLoads.get(sessionID) | |
| if (!load) return | |
| // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. | |
| const messages = data.message[sessionID] | |
| if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) | |
| const parts = load.touchedParts.get(messageID) | |
| if (parts) { | |
| parts.add(partID) | |
| return | |
| } | |
| load.touchedParts.set(messageID, new Set([partID])) | |
| } | |
| const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => { | |
| load.touchedMessages.clear() | |
| load.retainedMessages.clear() | |
| load.touchedParts.clear() | |
| load.carriedDeltaParts.clear() | |
| load.clearedMessageParts.clear() | |
| for (const messageID of load.removedMessages) { | |
| load.touchedMessages.add(messageID) | |
| load.clearedMessageParts.add(messageID) | |
| } | |
| for (const [messageID, parts] of load.deltaParts) { | |
| load.touchedParts.set(messageID, new Set(parts)) | |
| load.carriedDeltaParts.set(messageID, new Set(parts)) | |
| const messages = data.message[sessionID] | |
| if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) | |
| } | |
| for (const [messageID, parts] of load.removedParts) { | |
| const touched = load.touchedParts.get(messageID) ?? new Set<string>() | |
| parts.forEach((partID) => touched.add(partID)) | |
| load.touchedParts.set(messageID, touched) | |
| const messages = data.message[sessionID] | |
| if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) | |
| } | |
| for (const [messageID, parts] of load.optimisticParts) { | |
| load.removedMessages.delete(messageID) | |
| load.clearedMessageParts.add(messageID) | |
| load.touchedMessages.add(messageID) | |
| const touched = load.touchedParts.get(messageID) ?? new Set<string>() | |
| parts.forEach((partID) => touched.add(partID)) | |
| load.touchedParts.set(messageID, touched) | |
| } | |
| baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID)) | |
| baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID)) | |
| baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID)) | |
| baseline?.touchedParts.forEach((parts, messageID) => { | |
| const touched = load.touchedParts.get(messageID) ?? new Set<string>() | |
| parts.forEach((partID) => touched.add(partID)) | |
| load.touchedParts.set(messageID, touched) | |
| }) | |
| } | |
| const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({ | |
| touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)), | |
| retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)), | |
| touchedParts: new Map( | |
| [...load.touchedParts] | |
| .filter(([messageID]) => messageID !== exclude) | |
| .map(([messageID, parts]) => [messageID, new Set(parts)]), | |
| ), | |
| clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)), | |
| }) | |
| const evict = (sessionIDs: string[]) => { | |
| if (sessionIDs.length === 0) return | |
| const evicted = new Set(sessionIDs) | |
| for (const [partID, item] of deltaBases) { | |
| if (evicted.has(item.sessionID)) deltaBases.delete(partID) | |
| } | |
| sessionIDs.forEach((sessionID) => { | |
| generations.delete(sessionID) | |
| clearOptimistic(sessionID) | |
| requests.delete(sessionID) | |
| inflight.delete(sessionID) | |
| inflightTodo.delete(sessionID) | |
| messageLoads.delete(sessionID) | |
| v2.clear(sessionID) | |
| pendingParts.delete(sessionID) | |
| orphanParts.delete(sessionID) | |
| removedMessages.delete(sessionID) | |
| }) | |
| setData( | |
| produce((draft) => { | |
| dropSessionCaches(draft, sessionIDs) | |
| }), | |
| ) | |
| setMeta( | |
| produce((draft) => { | |
| for (const sessionID of sessionIDs) { | |
| delete draft.limit[sessionID] | |
| delete draft.cursor[sessionID] | |
| delete draft.complete[sessionID] | |
| delete draft.loading[sessionID] | |
| delete draft.at[sessionID] | |
| } | |
| }), | |
| ) | |
| } | |
| const protectedSessions = () => | |
| new Set([ | |
| ...pinned.keys(), | |
| ...requests.keys(), | |
| ...inflight.keys(), | |
| ...inflightTodo.keys(), | |
| ...messageLoads.keys(), | |
| ...optimistic.keys(), | |
| ...Object.entries(data.permission) | |
| .filter(([, items]) => items.length > 0) | |
| .map(([sessionID]) => sessionID), | |
| ...Object.entries(data.question) | |
| .filter(([, items]) => items.length > 0) | |
| .map(([sessionID]) => sessionID), | |
| ...Object.entries(data.session_status) | |
| .filter(([, status]) => status.type !== "idle") | |
| .map(([sessionID]) => sessionID), | |
| ]) | |
| const touch = (sessionID: string) => | |
| evict( | |
| pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), | |
| ) | |
| const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { | |
| if (messageApi && (await options?.protocol) !== "v1") { | |
| const request = (cursor?: string) => | |
| (options?.retry ?? retry)(() => { | |
| onAttempt?.() | |
| return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) | |
| }) | |
| const first = await request(before) | |
| const pages = [first] | |
| while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) { | |
| const response = await request(pages.at(-1)!.cursor.next ?? undefined) | |
| pages.push(response) | |
| if (!response.data.length) break | |
| } | |
| const response = pages.at(-1)! | |
| const source = pages.flatMap((page) => page.data).toReversed() | |
| const normalized = normalizeSessionMessages(sessionID, source) | |
| return { | |
| session: normalized.messages.sort(compareMessages), | |
| part: [...normalized.parts.entries()] | |
| .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) | |
| .sort((a, b) => cmp(a.id, b.id)), | |
| source, | |
| sourceMode: before ? ("older" as const) : ("latest" as const), | |
| projectSource: true, | |
| cursor: response.cursor.next ?? undefined, | |
| complete: response.data.length === 0, | |
| } | |
| } | |
| const response = await (options?.retry ?? retry)(() => { | |
| onAttempt?.() | |
| return client.session.messages({ sessionID, limit, before }) | |
| }) | |
| const items = (response.data ?? []).filter((item) => !!item?.info?.id) | |
| return { | |
| session: items.map((item) => cleanMessage(item.info)).sort(compareMessages), | |
| part: items.map((item) => ({ | |
| id: item.info.id, | |
| part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), | |
| })), | |
| source: legacyMessageSource(items), | |
| sourceMode: before ? ("older" as const) : ("latest" as const), | |
| cursor: response.response.headers.get("x-next-cursor") ?? undefined, | |
| complete: !response.response.headers.get("x-next-cursor"), | |
| } | |
| } | |
| const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { | |
| if (sessionApi && (await options?.protocol) !== "v1") { | |
| const response = await (options?.retry ?? retry)(() => { | |
| onAttempt?.() | |
| return sessionApi.message({ sessionID, messageID }) | |
| }) | |
| const normalized = normalizeSessionMessages(sessionID, [response]) | |
| const message = normalized.messages[0] | |
| if (!message) throw new Error(`Message not found: ${messageID}`) | |
| return { message, parts: normalized.parts.get(messageID) ?? [] } | |
| } | |
| const response = await (options?.retry ?? retry)(() => { | |
| onAttempt?.() | |
| return client.session.message({ sessionID, messageID }) | |
| }) | |
| if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`) | |
| return { | |
| message: cleanMessage(response.data.info), | |
| parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), | |
| } | |
| } | |
| const replaceMessages = (sessionID: string, messages: Message[]) => { | |
| const messageIDs = new Set(messages.map((message) => message.id)) | |
| const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id)) | |
| setData("message", sessionID, reconcile(messages, { key: "id" })) | |
| setData( | |
| produce((draft) => { | |
| for (const message of dropped) deleteMessageParts(draft, message.id) | |
| }), | |
| ) | |
| return messageIDs | |
| } | |
| const replaceParts = ( | |
| sessionID: string, | |
| items: MessagePage["part"], | |
| messageIDs: Set<string>, | |
| load?: MessageLoadState, | |
| ) => { | |
| for (const item of items) { | |
| if (!messageIDs.has(item.id)) continue | |
| const fetched = load?.clearedMessageParts.has(item.id) | |
| ? [] | |
| : item.part.filter((part) => !SKIP_PARTS.has(part.type)) | |
| const fetchedIDs = new Set(fetched.map((part) => part.id)) | |
| const pending = pendingParts.get(sessionID)?.get(item.id) | |
| const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])]) | |
| for (const part of fetched) { | |
| const accumulated = data.part_text_accum_delta[part.id] | |
| const base = deltaBases.get(part.id)?.base | |
| const preserveDelta = | |
| base !== undefined && | |
| accumulated !== undefined && | |
| "text" in part && | |
| typeof part.text === "string" && | |
| part.text.startsWith(base) && | |
| accumulated.startsWith(part.text) && | |
| accumulated !== part.text | |
| if (preserveDelta) touched.add(part.id) | |
| if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id) | |
| } | |
| for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) { | |
| if (!fetchedIDs.has(partID)) touched.delete(partID) | |
| } | |
| const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched }) | |
| if (!parts.length) { | |
| orphanParts.get(sessionID)?.delete(item.id) | |
| setData(produce((draft) => deleteMessageParts(draft, item.id))) | |
| continue | |
| } | |
| const partIDs = new Set(parts.map((part) => part.id)) | |
| setData( | |
| "part_text_accum_delta", | |
| produce((draft) => { | |
| for (const part of data.part[item.id] ?? []) { | |
| if (!partIDs.has(part.id) || !touched.has(part.id)) { | |
| delete draft[part.id] | |
| deltaBases.delete(part.id) | |
| } | |
| } | |
| }), | |
| ) | |
| setData("part", item.id, reconcile(parts, { key: "id" })) | |
| orphanParts.get(sessionID)?.delete(item.id) | |
| } | |
| } | |
| const applyMessagePage = ( | |
| sessionID: string, | |
| page: MessagePage, | |
| load: MessageLoadState | undefined, | |
| preserveUnfetched: boolean | ((message: Message) => boolean), | |
| cleanupOrphans: boolean, | |
| ) => { | |
| const source = page.source | |
| ? (() => { | |
| const incoming = new Map(page.source.map((message) => [message.id, message])) | |
| const existing = data.session_message[sessionID] ?? [] | |
| const current = existing.filter((message) => !incoming.has(message.id)) | |
| const live = new Map(existing.map((message) => [message.id, message])) | |
| return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( | |
| (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), | |
| ) | |
| })() | |
| : undefined | |
| const projected = | |
| page.projectSource && source | |
| ? (() => { | |
| const normalized = normalizeSessionMessages(sessionID, source) | |
| return { | |
| ...page, | |
| session: normalized.messages.sort(compareMessages), | |
| part: [...normalized.parts.entries()] | |
| .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) | |
| .sort((a, b) => cmp(a.id, b.id)), | |
| } | |
| })() | |
| : page | |
| const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])]) | |
| merged.observed.forEach((item) => { | |
| if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts) | |
| }) | |
| const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])]) | |
| const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { | |
| touched: touchedMessages, | |
| retained: load?.retainedMessages, | |
| removed: load?.removedMessages, | |
| preserveUnfetched, | |
| compare: compareMessages, | |
| }) | |
| batch(() => { | |
| if (source) setData("session_message", sessionID, reconcile(source)) | |
| const messageIDs = replaceMessages(sessionID, messages) | |
| replaceParts(sessionID, merged.part, messageIDs, load) | |
| const orphans = orphanParts.get(sessionID) | |
| if (cleanupOrphans && page.complete && orphans) { | |
| for (const messageID of orphans) { | |
| if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID))) | |
| } | |
| orphanParts.delete(sessionID) | |
| } | |
| setMeta("limit", sessionID, messages.length) | |
| setMeta("cursor", sessionID, merged.cursor) | |
| setMeta("complete", sessionID, merged.complete) | |
| setMeta("at", sessionID, Date.now()) | |
| }) | |
| } | |
| const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { | |
| if (meta.loading[sessionID]) return | |
| const active = generation(sessionID) | |
| const load: MessageLoadState = { | |
| touchedMessages: new Set(), | |
| removedMessages: new Set(), | |
| retainedMessages: new Set(), | |
| touchedParts: new Map(), | |
| deltaParts: new Map(), | |
| carriedDeltaParts: new Map(), | |
| removedParts: new Map(), | |
| optimisticParts: new Map(), | |
| orphanParents: new Set(), | |
| clearedMessageParts: new Set(), | |
| touchedSource: new Set(), | |
| } | |
| messageLoads.set(sessionID, load) | |
| setMeta("loading", sessionID, true) | |
| let applied = false | |
| try { | |
| const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) | |
| const first = page.session.reduce<Message | undefined>( | |
| (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), | |
| undefined, | |
| ) | |
| if (generations.get(sessionID) !== active) return | |
| const parents = [] as Awaited<ReturnType<typeof fetchMessage>>[] | |
| if (mode !== "prepend") { | |
| const users = new Set([ | |
| ...page.session.filter((message) => message.role === "user").map((message) => message.id), | |
| ...(data.message[sessionID] ?? []) | |
| .filter((message) => { | |
| if (message.role !== "user") return false | |
| const item = optimistic.get(sessionID)?.get(message.id) | |
| return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true) | |
| }) | |
| .map((message) => message.id), | |
| ]) | |
| const parentIDs = [ | |
| ...new Set( | |
| page.session.flatMap((message) => | |
| message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [], | |
| ), | |
| ), | |
| ] | |
| for (const parentID of parentIDs) { | |
| if (generations.get(sessionID) !== active) break | |
| const parent = await fetchMessage(sessionID, parentID, () => | |
| resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)), | |
| ).catch((error) => { | |
| const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined | |
| if (cause && "status" in cause && cause.status === 404) { | |
| load.removedMessages.add(parentID) | |
| return | |
| } | |
| throw error | |
| }) | |
| if (!parent) continue | |
| if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`) | |
| parents.push(parent) | |
| } | |
| } | |
| if (generations.get(sessionID) !== active) return | |
| const result = | |
| mode === "prepend" | |
| ? page | |
| : { | |
| ...page, | |
| session: merge( | |
| page.session, | |
| parents.map((parent) => parent.message), | |
| ).sort(compareMessages), | |
| part: merge( | |
| page.part, | |
| parents.map((parent) => ({ id: parent.message.id, part: parent.parts })), | |
| ), | |
| } | |
| const preserveUnfetched = | |
| mode === "prepend" || | |
| (!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0))) | |
| applyMessagePage( | |
| sessionID, | |
| result, | |
| messageLoads.get(sessionID) === load ? load : undefined, | |
| preserveUnfetched, | |
| mode !== "prepend", | |
| ) | |
| applied = true | |
| } finally { | |
| if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { | |
| for (const messageID of load.orphanParents) { | |
| if (!orphanParts.get(sessionID)?.has(messageID)) continue | |
| setData(produce((draft) => deleteMessageParts(draft, messageID))) | |
| orphanParts.get(sessionID)?.delete(messageID) | |
| } | |
| if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID) | |
| } | |
| if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID) | |
| if (generations.get(sessionID) === active) setMeta("loading", sessionID, false) | |
| } | |
| } | |
| const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { | |
| touch(sessionID) | |
| return runInflight(inflight, sessionID, async () => { | |
| const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined | |
| if (cached && data.info[sessionID] && !options?.force) return | |
| await Promise.all([ | |
| resolve(sessionID, options), | |
| cached && !options?.force | |
| ? Promise.resolve() | |
| : loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize), | |
| ]) | |
| }) | |
| } | |
| const prefetch = async (sessionID: string, limit: number) => { | |
| touch(sessionID) | |
| await inflight.get(sessionID) | |
| if ( | |
| Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 && | |
| (meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit) | |
| ) | |
| return | |
| await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit)) | |
| } | |
| const eventSessionID = (event: { type: string; properties?: unknown }) => { | |
| const properties = event.properties | |
| if (!properties || typeof properties !== "object") return | |
| if ("sessionID" in properties && typeof properties.sessionID === "string") return properties.sessionID | |
| if ( | |
| "info" in properties && | |
| properties.info && | |
| typeof properties.info === "object" && | |
| "sessionID" in properties.info && | |
| typeof properties.info.sessionID === "string" | |
| ) | |
| return properties.info.sessionID | |
| if ( | |
| "part" in properties && | |
| properties.part && | |
| typeof properties.part === "object" && | |
| "sessionID" in properties.part && | |
| typeof properties.part.sessionID === "string" | |
| ) | |
| return properties.part.sessionID | |
| } | |
| const projectV2 = (reduction: V2SessionReduction) => { | |
| reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) | |
| setData("session_message", reduction.sessionID, reconcile(reduction.messages)) | |
| if (reduction.touched.length === 0) return | |
| const touched = new Set(reduction.touched) | |
| let parentID: string | undefined | |
| for (const message of reduction.messages) { | |
| if (message.type === "user" || (message.type === "synthetic" && message.description?.trim())) | |
| parentID = message.id | |
| if (message.type === "shell") { | |
| if (touched.has(message.id)) touched.add(`${message.id}:assistant`) | |
| parentID = undefined | |
| } | |
| if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID) | |
| if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID) | |
| } | |
| const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) | |
| batch(() => { | |
| for (const message of normalized.messages) { | |
| if (!touched.has(message.id)) continue | |
| apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) | |
| } | |
| for (const messageID of touched) { | |
| const next = normalized.parts.get(messageID) ?? [] | |
| const nextIDs = new Set(next.map((part) => part.id)) | |
| for (const part of next) { | |
| apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) | |
| } | |
| for (const part of data.part[messageID] ?? []) { | |
| if (nextIDs.has(part.id)) continue | |
| apply({ | |
| type: "message.part.removed", | |
| properties: { sessionID: reduction.sessionID, messageID, partID: part.id }, | |
| }) | |
| } | |
| } | |
| }) | |
| } | |
| const hydrateV2Message = (sessionID: string, messageID: string) => { | |
| if (!sessionApi) return | |
| void sessionApi | |
| .message({ sessionID, messageID }) | |
| .then((message) => { | |
| const current = data.session_message[sessionID] ?? [] | |
| const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages) | |
| projectV2({ sessionID, messages, touched: [message.id] }) | |
| }) | |
| .catch(() => {}) | |
| } | |
| const applyV2 = (event: OpenCodeEvent) => { | |
| if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return | |
| const sessionID = event.data.sessionID | |
| const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) | |
| if (reduction) { | |
| projectV2(reduction) | |
| if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) | |
| } | |
| const info = data.info[sessionID] | |
| if (event.type === "session.renamed" && info) | |
| remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) | |
| if (event.type === "session.moved" && info) | |
| remember({ | |
| ...info, | |
| projectID: event.data.projectID ?? info.projectID, | |
| workspaceID: event.data.location.workspaceID, | |
| directory: event.data.location.directory, | |
| path: event.data.subpath, | |
| time: { ...info.time, updated: event.created }, | |
| }) | |
| if (event.type === "session.usage.updated" && info) | |
| remember({ ...info, cost: event.data.cost, tokens: event.data.tokens }) | |
| // if (event.type === "session.archived") { | |
| // if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } }) | |
| // evict([sessionID]) | |
| // } | |
| if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" }) | |
| if ( | |
| event.type === "session.execution.succeeded" || | |
| event.type === "session.execution.failed" || | |
| event.type === "session.execution.interrupted" | |
| ) | |
| setData("session_status", sessionID, { type: "idle" }) | |
| if (event.type === "session.retry.scheduled") | |
| setData("session_status", sessionID, { | |
| type: "retry", | |
| attempt: event.data.attempt, | |
| message: event.data.error.message, | |
| next: event.data.at, | |
| }) | |
| if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) | |
| if ( | |
| event.type === "session.revert.staged" || | |
| event.type === "session.revert.cleared" || | |
| event.type === "session.revert.committed" | |
| ) | |
| void resolve(sessionID, { force: true }).catch(() => {}) | |
| } | |
| const apply = (event: { type: string; properties?: unknown }) => { | |
| const eventID = eventSessionID(event) | |
| if (eventID) { | |
| touch(eventID) | |
| if ( | |
| !data.info[eventID] && | |
| event.type !== "session.created" && | |
| event.type !== "session.updated" && | |
| event.type !== "session.deleted" | |
| ) | |
| void resolve(eventID).catch(() => {}) | |
| } | |
| switch (event.type) { | |
| case "session.created": | |
| remember((event.properties as { info: Session }).info) | |
| return | |
| case "session.updated": { | |
| const info = (event.properties as { info: Session }).info | |
| remember(info) | |
| if (info.time.archived) evict([info.id]) | |
| return | |
| } | |
| case "session.deleted": { | |
| const properties = event.properties as { sessionID?: string; info?: Session } | |
| const sessionID = properties.info?.id ?? properties.sessionID | |
| if (!sessionID) return | |
| infoSeen.delete(sessionID) | |
| setData( | |
| "info", | |
| produce((draft) => void delete draft[sessionID]), | |
| ) | |
| evict([sessionID]) | |
| return | |
| } | |
| case "todo.updated": { | |
| const props = event.properties as { sessionID: string; todos: Todo[] } | |
| setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) | |
| return | |
| } | |
| case "session.status": { | |
| const props = event.properties as { sessionID: string; status: SessionStatus } | |
| setData("session_status", props.sessionID, reconcile(props.status)) | |
| return | |
| } | |
| case "message.updated": { | |
| const info = cleanMessage((event.properties as { info: Message }).info) | |
| indexLegacyMessage(info) | |
| const load = messageLoads.get(info.sessionID) | |
| load?.touchedMessages.add(info.id) | |
| load?.removedMessages.delete(info.id) | |
| const items = optimistic.get(info.sessionID) | |
| const item = items?.get(info.id) | |
| if (items && item) { | |
| if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id) | |
| if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true }) | |
| } | |
| const orphans = orphanParts.get(info.sessionID) | |
| orphans?.delete(info.id) | |
| if (orphans?.size === 0) orphanParts.delete(info.sessionID) | |
| const removedMessagesForSession = removedMessages.get(info.sessionID) | |
| removedMessagesForSession?.delete(info.id) | |
| if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID) | |
| const messages = data.message[info.sessionID] | |
| if (!messages) { | |
| setData("message", info.sessionID, [info]) | |
| return | |
| } | |
| const result = Binary.search(messages, messageKey(info), messageKey) | |
| if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) | |
| if (!result.found) | |
| setData("message", info.sessionID, (value = []) => { | |
| const next = value.slice() | |
| next.splice(result.index, 0, info) | |
| return next | |
| }) | |
| return | |
| } | |
| case "message.removed": { | |
| const props = event.properties as { sessionID: string; messageID: string } | |
| setData("session_message", props.sessionID, (messages) => | |
| messages?.filter((message) => message.id !== props.messageID), | |
| ) | |
| const load = messageLoads.get(props.sessionID) | |
| load?.touchedMessages.add(props.messageID) | |
| load?.removedMessages.add(props.messageID) | |
| load?.clearedMessageParts.add(props.messageID) | |
| load?.deltaParts.delete(props.messageID) | |
| load?.carriedDeltaParts.delete(props.messageID) | |
| load?.removedParts.delete(props.messageID) | |
| load?.optimisticParts.delete(props.messageID) | |
| pendingParts.get(props.sessionID)?.delete(props.messageID) | |
| if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID) | |
| const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>() | |
| removedMessagesForSession.add(props.messageID) | |
| removedMessages.set(props.sessionID, removedMessagesForSession) | |
| clearOptimistic(props.sessionID, props.messageID) | |
| setData( | |
| produce((draft) => { | |
| const messages = draft.message[props.sessionID] | |
| if (messages) { | |
| const index = messages.findIndex((message) => message.id === props.messageID) | |
| if (index >= 0) messages.splice(index, 1) | |
| } | |
| deleteMessageParts(draft, props.messageID) | |
| }), | |
| ) | |
| return | |
| } | |
| case "message.part.updated": { | |
| const part = (event.properties as { part: Part }).part | |
| if (SKIP_PARTS.has(part.type)) return | |
| const messages = data.message[part.sessionID] | |
| const load = messageLoads.get(part.sessionID) | |
| const missing = !messages?.some((message) => message.id === part.messageID) | |
| // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. | |
| if ( | |
| missing && | |
| (!load || | |
| load.clearedMessageParts.has(part.messageID) || | |
| removedMessages.get(part.sessionID)?.has(part.messageID)) | |
| ) | |
| return | |
| if (missing) { | |
| const orphans = orphanParts.get(part.sessionID) ?? new Set<string>() | |
| orphans.add(part.messageID) | |
| orphanParts.set(part.sessionID, orphans) | |
| load?.orphanParents.add(part.messageID) | |
| } | |
| const deltas = load?.deltaParts.get(part.messageID) | |
| deltas?.delete(part.id) | |
| if (deltas?.size === 0) load?.deltaParts.delete(part.messageID) | |
| const carried = load?.carriedDeltaParts.get(part.messageID) | |
| carried?.delete(part.id) | |
| if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID) | |
| const removed = load?.removedParts.get(part.messageID) | |
| removed?.delete(part.id) | |
| if (removed?.size === 0) load?.removedParts.delete(part.messageID) | |
| const pending = pendingParts.get(part.sessionID)?.get(part.messageID) | |
| pending?.delete(part.id) | |
| if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID) | |
| if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID) | |
| const optimistic = load?.optimisticParts.get(part.messageID) | |
| optimistic?.delete(part.id) | |
| if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID) | |
| deltaBases.delete(part.id) | |
| trackPartChange(part.sessionID, part.messageID, part.id) | |
| confirmOptimisticPart(part.sessionID, part.messageID, part) | |
| setData( | |
| "part_text_accum_delta", | |
| produce((draft) => void delete draft[part.id]), | |
| ) | |
| const parts = data.part[part.messageID] | |
| if (!parts) { | |
| setData("part", part.messageID, [part]) | |
| return | |
| } | |
| const result = Binary.search(parts, part.id, (item) => item.id) | |
| if (result.found) setData("part", part.messageID, result.index, reconcile(part)) | |
| if (!result.found) | |
| setData("part", part.messageID, (value = []) => { | |
| const next = value.slice() | |
| next.splice(result.index, 0, part) | |
| return next | |
| }) | |
| return | |
| } | |
| case "message.part.removed": { | |
| const props = event.properties as { sessionID: string; messageID: string; partID: string } | |
| // Part removal is event-only on the server, so its tombstone lasts until a later update or eviction. | |
| const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>() | |
| const parts = pending.get(props.messageID) ?? new Set<string>() | |
| parts.add(props.partID) | |
| pending.set(props.messageID, parts) | |
| pendingParts.set(props.sessionID, pending) | |
| const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID) | |
| deltas?.delete(props.partID) | |
| if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID) | |
| const load = messageLoads.get(props.sessionID) | |
| const carried = load?.carriedDeltaParts.get(props.messageID) | |
| carried?.delete(props.partID) | |
| if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID) | |
| if (load) { | |
| const parts = load.removedParts.get(props.messageID) ?? new Set<string>() | |
| parts.add(props.partID) | |
| load.removedParts.set(props.messageID, parts) | |
| const optimistic = load.optimisticParts.get(props.messageID) | |
| optimistic?.delete(props.partID) | |
| if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID) | |
| } | |
| trackPartChange(props.sessionID, props.messageID, props.partID) | |
| clearOptimisticPart(props.sessionID, props.messageID, props.partID) | |
| setData( | |
| produce((draft) => { | |
| delete draft.part_text_accum_delta[props.partID] | |
| deltaBases.delete(props.partID) | |
| const parts = draft.part[props.messageID] | |
| if (!parts) return | |
| const result = Binary.search(parts, props.partID, (part) => part.id) | |
| if (result.found) parts.splice(result.index, 1) | |
| if (parts.length === 0) delete draft.part[props.messageID] | |
| }), | |
| ) | |
| return | |
| } | |
| case "message.part.delta": { | |
| const props = event.properties as { | |
| sessionID: string | |
| messageID: string | |
| partID: string | |
| field: string | |
| delta: string | |
| } | |
| const parts = data.part[props.messageID] | |
| if (!parts) return | |
| const result = Binary.search(parts, props.partID, (part) => part.id) | |
| if (!result.found) return | |
| trackPartChange(props.sessionID, props.messageID, props.partID) | |
| const load = messageLoads.get(props.sessionID) | |
| if (load) { | |
| const parts = load.deltaParts.get(props.messageID) ?? new Set<string>() | |
| parts.add(props.partID) | |
| load.deltaParts.set(props.messageID, parts) | |
| const carried = load.carriedDeltaParts.get(props.messageID) | |
| carried?.delete(props.partID) | |
| if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID) | |
| } | |
| const field = props.field as keyof (typeof parts)[number] | |
| const current = parts[result.index]?.[field] | |
| if (!deltaBases.has(props.partID) && typeof current === "string") | |
| deltaBases.set(props.partID, { base: current, sessionID: props.sessionID }) | |
| setData( | |
| "part_text_accum_delta", | |
| props.partID, | |
| (value) => (value ?? (typeof current === "string" ? current : "")) + props.delta, | |
| ) | |
| setData( | |
| "part", | |
| props.messageID, | |
| produce((draft) => { | |
| if (!draft) return | |
| const part = draft[result.index] | |
| const field = props.field as keyof typeof part | |
| ;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta | |
| }), | |
| ) | |
| return | |
| } | |
| case "permission.asked": { | |
| const permission = event.properties as PermissionRequest | |
| const permissions = data.permission[permission.sessionID] | |
| if (!permissions) { | |
| setData("permission", permission.sessionID, [permission]) | |
| return | |
| } | |
| const result = Binary.search(permissions, permission.id, (item) => item.id) | |
| if (result.found) setData("permission", permission.sessionID, result.index, reconcile(permission)) | |
| if (!result.found) | |
| setData( | |
| "permission", | |
| permission.sessionID, | |
| produce((draft) => void draft.splice(result.index, 0, permission)), | |
| ) | |
| return | |
| } | |
| case "permission.replied": { | |
| const props = event.properties as { sessionID: string; requestID: string } | |
| setData( | |
| "permission", | |
| props.sessionID, | |
| produce((draft) => { | |
| if (!draft) return | |
| const result = Binary.search(draft, props.requestID, (item) => item.id) | |
| if (result.found) draft.splice(result.index, 1) | |
| }), | |
| ) | |
| return | |
| } | |
| case "question.asked": { | |
| const question = event.properties as QuestionRequest | |
| const questions = data.question[question.sessionID] | |
| if (!questions) { | |
| setData("question", question.sessionID, [question]) | |
| return | |
| } | |
| const result = Binary.search(questions, question.id, (item) => item.id) | |
| if (result.found) setData("question", question.sessionID, result.index, reconcile(question)) | |
| if (!result.found) | |
| setData( | |
| "question", | |
| question.sessionID, | |
| produce((draft) => void draft.splice(result.index, 0, question)), | |
| ) | |
| return | |
| } | |
| case "question.replied": | |
| case "question.rejected": { | |
| const props = event.properties as { sessionID: string; requestID: string } | |
| setData( | |
| "question", | |
| props.sessionID, | |
| produce((draft) => { | |
| if (!draft) return | |
| const result = Binary.search(draft, props.requestID, (item) => item.id) | |
| if (result.found) draft.splice(result.index, 1) | |
| }), | |
| ) | |
| } | |
| } | |
| } | |
| return { | |
| data, | |
| set: setData, | |
| get: (sessionID: string) => data.info[sessionID], | |
| peek: (sessionID: string) => data.info[sessionID], | |
| remember, | |
| resolve, | |
| lineage: { | |
| peek: peekLineage, | |
| async resolve(sessionID: string) { | |
| const session = await resolve(sessionID) | |
| return { session, root: await rootSession(session, resolve) } | |
| }, | |
| }, | |
| sync, | |
| prefetch, | |
| shouldPrefetch(sessionID: string, limit: number) { | |
| if (data.message[sessionID] === undefined) return true | |
| if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true | |
| if (meta.complete[sessionID]) return false | |
| return (meta.limit[sessionID] ?? 0) <= limit | |
| }, | |
| fresh(sessionID: string, ttl: number) { | |
| return Date.now() - (meta.at[sessionID] ?? 0) <= ttl | |
| }, | |
| optimistic: { | |
| add(input: { sessionID: string; message: Message; parts: Part[] }) { | |
| const parts = input.parts | |
| .filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)) | |
| .sort((a, b) => cmp(a.id, b.id)) | |
| const load = messageLoads.get(input.sessionID) | |
| if (load?.clearedMessageParts.has(input.message.id)) { | |
| const touched = load.touchedParts.get(input.message.id) ?? new Set<string>() | |
| parts.forEach((part) => touched.add(part.id)) | |
| load.touchedParts.set(input.message.id, touched) | |
| } | |
| if (load) { | |
| load.removedMessages.delete(input.message.id) | |
| load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id))) | |
| } | |
| const items = optimistic.get(input.sessionID) | |
| const removedMessagesForSession = removedMessages.get(input.sessionID) | |
| removedMessagesForSession?.delete(input.message.id) | |
| if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID) | |
| if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) | |
| if (!items) | |
| optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) | |
| setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages)) | |
| setData( | |
| "part_text_accum_delta", | |
| produce((draft) => { | |
| for (const part of [...(data.part[input.message.id] ?? []), ...parts]) { | |
| delete draft[part.id] | |
| deltaBases.delete(part.id) | |
| } | |
| }), | |
| ) | |
| setData("part", input.message.id, parts) | |
| }, | |
| remove(input: { sessionID: string; messageID: string }) { | |
| const item = optimistic.get(input.sessionID)?.get(input.messageID) | |
| if (!item) return | |
| messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID) | |
| clearOptimistic(input.sessionID, input.messageID) | |
| if (item.confirmedMessage) { | |
| const partIDs = new Set(item.parts.map((part) => part.id)) | |
| setData( | |
| produce((draft) => { | |
| for (const part of item.parts) { | |
| delete draft.part_text_accum_delta[part.id] | |
| deltaBases.delete(part.id) | |
| } | |
| const parts = draft.part[input.messageID] | |
| if (!parts) return | |
| draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id)) | |
| if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID] | |
| }), | |
| ) | |
| return | |
| } | |
| setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) | |
| setData(produce((draft) => deleteMessageParts(draft, input.messageID))) | |
| }, | |
| }, | |
| async todo(sessionID: string, request?: { force?: boolean }) { | |
| touch(sessionID) | |
| if (data.todo[sessionID] !== undefined && !request?.force) return | |
| if ((await options?.protocol) === "v2") { | |
| setData("todo", sessionID, []) | |
| return | |
| } | |
| return runInflight(inflightTodo, sessionID, () => { | |
| const active = generation(sessionID) | |
| return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => { | |
| if (generations.get(sessionID) !== active) return | |
| setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) | |
| }) | |
| }) | |
| }, | |
| history: { | |
| more: (sessionID: string) => | |
| data.message[sessionID] !== undefined && | |
| meta.limit[sessionID] !== undefined && | |
| !meta.complete[sessionID] && | |
| !!meta.cursor[sessionID], | |
| loading: (sessionID: string) => meta.loading[sessionID] ?? false, | |
| async loadMore(sessionID: string, count = historyMessagePageSize) { | |
| touch(sessionID) | |
| if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return | |
| await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend") | |
| }, | |
| }, | |
| evict(sessionID: string) { | |
| if (protectedSessions().has(sessionID)) return | |
| seen.delete(sessionID) | |
| evict([sessionID]) | |
| }, | |
| pin(sessionID: string) { | |
| pinned.set(sessionID, (pinned.get(sessionID) ?? 0) + 1) | |
| touch(sessionID) | |
| }, | |
| unpin(sessionID: string) { | |
| const count = pinned.get(sessionID) | |
| if (!count || count === 1) pinned.delete(sessionID) | |
| if (count && count > 1) pinned.set(sessionID, count - 1) | |
| }, | |
| apply, | |
| applyV2, | |
| } | |
| } | |
| export type ServerSession = ReturnType<typeof createServerSession> | |