diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eb8ed2d9bba..d67dba8b924 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -28,6 +28,11 @@ import { useArgs } from "./args" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" import type { Path } from "@opencode-ai/sdk" +import { parseLinkHeader } from "@/util/link-header" +import { evictFromEnd, evictFromStart, windowNewest, windowOldest } from "@tui/util/pagination" + +/** Maximum messages kept in memory per session */ +const MAX_LOADED_MESSAGES = 500 export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -48,6 +53,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } config: Config session: Session[] + message_page: { + [sessionID: string]: { + hasOlder: boolean + hasNewer: boolean + loading: boolean + loadingDirection?: "older" | "newer" + oldest?: string + newest?: string + error?: string + } + } session_status: { [sessionID: string]: SessionStatus } @@ -89,6 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ provider: [], provider_default: {}, session: [], + message_page: {}, session_status: {}, session_diff: {}, todo: {}, @@ -104,6 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() + const getRevertMarker = (sessionID: string) => { + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (!match.found) return undefined + return store.session[match.index].revert?.messageID + } + sdk.event.listen((e) => { const event = e.details switch (event.type) { @@ -226,40 +249,74 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { - const messages = store.message[event.properties.info.sessionID] + const sessionID = event.properties.info.sessionID + const page = store.message_page[sessionID] + const messages = store.message[sessionID] + const pinned = getRevertMarker(sessionID) if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) + setStore("message", sessionID, [event.properties.info]) break } const result = Binary.search(messages, event.properties.info.id, (m) => m.id) if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + setStore("message", sessionID, result.index, reconcile(event.properties.info)) + break + } + const loadingNewer = page?.loading && page.loadingDirection === "newer" + const loadingOlder = page?.loading && page.loadingDirection === "older" + if (page?.hasNewer && !loadingNewer) { + break + } + if (page?.oldest && event.properties.info.id < page.oldest && !loadingOlder) { break } setStore( "message", - event.properties.info.sessionID, + sessionID, produce((draft) => { draft.splice(result.index, 0, event.properties.info) }), ) const updated = store.message[event.properties.info.sessionID] - if (updated.length > 100) { - const oldest = updated[0] + if (page) { + const nextOldest = windowOldest(updated, pinned) ?? page.oldest + const nextNewest = windowNewest(updated, pinned) ?? page.newest + setStore("message_page", event.properties.info.sessionID, { + ...page, + newest: nextNewest, + oldest: nextOldest, + }) + } + if (updated.length > MAX_LOADED_MESSAGES) { + const evictCount = updated.length - MAX_LOADED_MESSAGES + const preview = [...updated] + const evicted = evictFromStart(preview, evictCount, pinned) + const nextOldest = windowOldest(preview, pinned) ?? page?.oldest + const nextNewest = windowNewest(preview, pinned) ?? page?.newest batch(() => { setStore( "message", event.properties.info.sessionID, produce((draft) => { - draft.shift() + evictFromStart(draft, evictCount, pinned) }), ) setStore( "part", produce((draft) => { - delete draft[oldest.id] + for (const msg of evicted) { + delete draft[msg.id] + } }), ) + if (page) { + setStore("message_page", event.properties.info.sessionID, { + ...page, + hasOlder: true, + oldest: nextOldest, + newest: nextNewest, + }) + } }) } break @@ -279,6 +336,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "message.part.updated": { + const sessionID = event.properties.part.sessionID + const page = store.message_page[sessionID] + const messages = store.message[sessionID] + const messageExists = messages?.some((m) => m.id === event.properties.part.messageID) + const loadingNewer = page?.loading && page.loadingDirection === "newer" + if (!messageExists && !loadingNewer) { + break + } const parts = store.part[event.properties.part.messageID] if (!parts) { setStore("part", event.properties.part.messageID, [event.properties.part]) @@ -414,6 +479,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) const fullSyncedSessions = new Set() + const loadingGuard = new Set() const result = { data: store, set: setStore, @@ -447,21 +513,350 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) + const link = messages.response.headers.get("link") ?? "" + const hasOlder = parseLinkHeader(link).prev !== undefined + const pageMessages = messages.data ?? [] + const oldest = pageMessages.at(0)?.info.id + const newest = pageMessages.at(-1)?.info.id + const revertMessageID = session.data?.revert?.messageID + const mergedMessages = await (async () => { + if (!revertMessageID) return pageMessages + if (pageMessages.some((m) => m.info.id === revertMessageID)) return pageMessages + try { + const revertResult = await sdk.client.session.message( + { sessionID, messageID: revertMessageID }, + { throwOnError: true }, + ) + if (revertResult.data) return [revertResult.data, ...pageMessages] + } catch (e) { + Log.Default.info("Revert marker fetch failed during sync", { + messageID: revertMessageID, + error: e, + }) + } + return pageMessages + })() + const nextOldest = oldest ?? mergedMessages.at(0)?.info.id + const nextNewest = newest ?? mergedMessages.at(-1)?.info.id setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + draft.message[sessionID] = mergedMessages.map((x) => x.info) + for (const message of mergedMessages) { draft.part[message.info.id] = message.parts } draft.session_diff[sessionID] = diff.data ?? [] + draft.message_page[sessionID] = { + hasOlder, + hasNewer: false, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } }), ) fullSyncedSessions.add(sessionID) }, + async loadOlder(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasOlder) return + const messages = store.message[sessionID] ?? [] + const cursor = page?.oldest ?? messages.at(0)?.id + if (!cursor) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + const pinned = getRevertMarker(sessionID) + try { + setStore("message_page", sessionID, { ...page, loading: true, loadingDirection: "older", error: undefined }) + + const res = await sdk.client.session.messages( + { sessionID, before: cursor, limit: 100 }, + { throwOnError: true }, + ) + const link = res.response.headers.get("link") ?? "" + const hasOlder = parseLinkHeader(link).prev !== undefined + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + const pageOldest = res.data?.at(0)?.info.id + for (const msg of res.data ?? []) { + const match = Binary.search(existing, msg.info.id, (m) => m.id) + if (!match.found) { + existing.splice(match.index, 0, msg.info) + draft.part[msg.info.id] = msg.parts + } + } + const nextOldest = pageOldest ?? draft.message_page[sessionID]?.oldest + if (existing.length > MAX_LOADED_MESSAGES) { + const evictCount = existing.length - MAX_LOADED_MESSAGES + const evicted = evictFromEnd(existing, evictCount, pinned) + for (const msg of evicted) delete draft.part[msg.id] + const nextNewest = windowNewest(existing, pinned) ?? draft.message_page[sessionID]?.newest + draft.message_page[sessionID] = { + hasOlder, + hasNewer: true, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + } else { + const nextNewest = windowNewest(existing, pinned) ?? draft.message_page[sessionID]?.newest + draft.message_page[sessionID] = { + hasOlder, + hasNewer: draft.message_page[sessionID]?.hasNewer ?? false, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + } + }), + ) + } catch (e) { + const page = store.message_page[sessionID] + setStore("message_page", sessionID, { + hasOlder: page?.hasOlder ?? false, + hasNewer: page?.hasNewer ?? false, + loading: false, + oldest: page?.oldest, + newest: page?.newest, + error: e instanceof Error ? e.message : String(e), + }) + } finally { + loadingGuard.delete(sessionID) + } + }, + async loadNewer(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasNewer) return + const messages = store.message[sessionID] ?? [] + const cursor = page?.newest ?? messages.at(-1)?.id + if (!cursor) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + const pinned = getRevertMarker(sessionID) + try { + setStore("message_page", sessionID, { ...page, loading: true, loadingDirection: "newer", error: undefined }) + const res = await sdk.client.session.messages( + { sessionID, after: cursor, limit: 100 }, + { throwOnError: true }, + ) + const link = res.response.headers.get("link") ?? "" + const hasNewer = parseLinkHeader(link).next !== undefined + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + const pageNewest = res.data?.at(-1)?.info.id + for (const msg of res.data ?? []) { + const match = Binary.search(existing, msg.info.id, (m) => m.id) + if (!match.found) { + existing.splice(match.index, 0, msg.info) + draft.part[msg.info.id] = msg.parts + } + } + const nextNewest = pageNewest ?? draft.message_page[sessionID]?.newest + if (existing.length > MAX_LOADED_MESSAGES) { + const evictCount = existing.length - MAX_LOADED_MESSAGES + const evicted = evictFromStart(existing, evictCount, pinned) + for (const msg of evicted) delete draft.part[msg.id] + const nextOldest = windowOldest(existing, pinned) ?? draft.message_page[sessionID]?.oldest + draft.message_page[sessionID] = { + hasOlder: true, + hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + } else { + const nextOldest = windowOldest(existing, pinned) ?? draft.message_page[sessionID]?.oldest + draft.message_page[sessionID] = { + hasOlder: draft.message_page[sessionID]?.hasOlder ?? false, + hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + } + }), + ) + } catch (e) { + const page = store.message_page[sessionID] + setStore("message_page", sessionID, { + hasOlder: page?.hasOlder ?? false, + hasNewer: page?.hasNewer ?? false, + loading: false, + oldest: page?.oldest, + newest: page?.newest, + error: e instanceof Error ? e.message : String(e), + }) + } finally { + loadingGuard.delete(sessionID) + } + }, + async jumpToLatest(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasNewer) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + + try { + // Check for revert state + const session = store.session.find((s) => s.id === sessionID) + const revertMessageID = session?.revert?.messageID + + setStore("message_page", sessionID, { + ...page, + loading: true, + loadingDirection: "newer", + error: undefined, + }) + + // Fetch newest page (no cursor = newest) + const res = await sdk.client.session.messages({ sessionID, limit: 100 }, { throwOnError: true }) + + let messages = res.data ?? [] + const pageOldest = messages.at(0)?.info.id + const pageNewest = messages.at(-1)?.info.id + const link = res.response.headers.get("link") ?? "" + const hasOlder = parseLinkHeader(link).prev !== undefined + + // Revert-aware: If in revert state and marker not in results, fetch it + if (revertMessageID && !messages.some((m) => m.info.id === revertMessageID)) { + try { + const revertResult = await sdk.client.session.message( + { sessionID, messageID: revertMessageID }, + { throwOnError: true }, + ) + if (revertResult.data) { + // Prepend revert message (it's older than newest page) + messages = [revertResult.data, ...messages] + } + } catch (e) { + // Revert message may have been deleted, continue without it + Log.Default.info("Revert marker fetch failed (may be deleted)", { + messageID: revertMessageID, + error: e, + }) + } + } + + const nextOldest = pageOldest ?? messages.at(0)?.info.id + const nextNewest = pageNewest ?? messages.at(-1)?.info.id + + setStore( + produce((draft) => { + // Clean up parts only for messages not in new results + const oldMessages = draft.message[sessionID] ?? [] + const newIds = new Set(messages.map((m) => m.info.id)) + for (const msg of oldMessages) { + if (!newIds.has(msg.id)) { + delete draft.part[msg.id] + } + } + + // Store new messages + draft.message[sessionID] = messages.map((m) => m.info) + for (const msg of messages) { + draft.part[msg.info.id] = msg.parts + } + draft.message_page[sessionID] = { + hasOlder, + hasNewer: false, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + }), + ) + } catch (e) { + setStore( + produce((draft) => { + const p = draft.message_page[sessionID] + if (p) { + p.loading = false + p.error = e instanceof Error ? e.message : String(e) + } + }), + ) + } finally { + loadingGuard.delete(sessionID) + } + }, + async jumpToOldest(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasOlder) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + + try { + setStore("message_page", sessionID, { + ...page, + loading: true, + loadingDirection: "older", + error: undefined, + }) + + const res = await sdk.client.session.messages( + { sessionID, oldest: true, limit: 100 }, + { throwOnError: true }, + ) + + const messages = res.data ?? [] + const pageOldest = messages.at(0)?.info.id + const pageNewest = messages.at(-1)?.info.id + const link = res.response.headers.get("link") ?? "" + const hasNewer = parseLinkHeader(link).next !== undefined + const nextOldest = pageOldest ?? messages.at(0)?.info.id + const nextNewest = pageNewest ?? messages.at(-1)?.info.id + + setStore( + produce((draft) => { + // Clean up parts only for messages not in new results + const oldMessages = draft.message[sessionID] ?? [] + const newIds = new Set(messages.map((m) => m.info.id)) + for (const msg of oldMessages) { + if (!newIds.has(msg.id)) { + delete draft.part[msg.id] + } + } + + // Store new messages + draft.message[sessionID] = messages.map((m) => m.info) + for (const msg of messages) { + draft.part[msg.info.id] = msg.parts + } + draft.message_page[sessionID] = { + hasOlder: false, + hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + error: undefined, + } + }), + ) + } catch (e) { + setStore( + produce((draft) => { + const p = draft.message_page[sessionID] + if (p) { + p.loading = false + p.error = e instanceof Error ? e.message : String(e) + } + }), + ) + } finally { + loadingGuard.delete(sessionID) + } + }, }, bootstrap, } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5f47562d2e3..45d183acf37 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -119,6 +119,7 @@ export function Session() { .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const paging = createMemo(() => sync.data.message_page[route.sessionID]) const permissions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) @@ -128,6 +129,53 @@ export function Session() { return children().flatMap((x) => sync.data.question[x.id] ?? []) }) + const LOAD_MORE_THRESHOLD = 5 + + const loadOlder = () => { + const page = paging() + if (!page?.hasOlder || page.loading || !scroll) return + if (scroll.scrollTop > LOAD_MORE_THRESHOLD) return + + const anchor = (() => { + const scrollTop = scroll.scrollTop + const children = scroll.getChildren() + for (const child of children) { + if (!child.id) continue + if (child.y + child.height > scrollTop) { + return { id: child.id, offset: scrollTop - child.y } + } + } + return undefined + })() + + const height = scroll.scrollHeight + const scrollTop = scroll.scrollTop + sync.session.loadOlder(route.sessionID).then(() => { + queueMicrotask(() => { + requestAnimationFrame(() => { + if (anchor) { + const child = scroll.getChildren().find((item) => item.id === anchor.id) + if (child) { + scroll.scrollTo(child.y + anchor.offset) + return + } + } + + const delta = scroll.scrollHeight - height + if (delta > 0) scroll.scrollTo(scrollTop + delta) + }) + }) + }) + } + + const loadNewer = () => { + const page = paging() + if (!page?.hasNewer || page.loading || !scroll) return + const bottomDistance = scroll.scrollHeight - scroll.scrollTop - scroll.viewport.height + if (bottomDistance > LOAD_MORE_THRESHOLD) return + sync.session.loadNewer(route.sessionID) + } + const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id }) @@ -230,7 +278,7 @@ export function Session() { const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() const messagesList = messages() - const scrollTop = scroll.y + const scrollTop = scroll.scrollTop // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content const visibleMessages = children @@ -268,7 +316,7 @@ export function Session() { } const child = scroll.getChildren().find((c) => c.id === targetID) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1) dialog.clear() } @@ -347,7 +395,7 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === messageID }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1) }} sessionID={route.sessionID} setPrompt={(promptInfo) => prompt.set(promptInfo)} @@ -370,7 +418,7 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === messageID }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1) }} sessionID={route.sessionID} /> @@ -631,7 +679,16 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollTo(0) + const page = paging() + if (page?.hasOlder && !page.loading) { + sync.session.jumpToOldest(route.sessionID).then(() => { + requestAnimationFrame(() => { + if (scroll) scroll.scrollTo(0) + }) + }) + } else { + scroll.scrollTo(0) + } dialog.clear() }, }, @@ -642,7 +699,16 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollTo(scroll.scrollHeight) + const page = paging() + if (page?.hasNewer && !page.loading) { + sync.session.jumpToLatest(route.sessionID).then(() => { + requestAnimationFrame(() => { + if (scroll) scroll.scrollTo(scroll.scrollHeight) + }) + }) + } else { + scroll.scrollTo(scroll.scrollHeight) + } dialog.clear() }, }, @@ -672,7 +738,7 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === message.id }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) scroll.scrollBy(child.y - scroll.scrollTop - 1) break } } @@ -942,8 +1008,38 @@ export function Session() {
+ + + Loading older messages... + + + + + (scroll up for more) + + + + + Failed to load: {paging()?.error} + (scroll to retry) + + (scroll = r)} + onMouseScroll={() => { + loadOlder() + loadNewer() + }} + onKeyDown={(e) => { + // Standard scroll triggers incremental load + if (["up", "pageup", "home"].includes(e.name)) { + setTimeout(loadOlder, 0) + } + if (["down", "pagedown", "end"].includes(e.name)) { + setTimeout(loadNewer, 0) + } + }} + viewportCulling={true} viewportOptions={{ paddingRight: showScrollbar() ? 1 : 0, }} @@ -1056,6 +1152,16 @@ export function Session() { )} + + + Loading newer messages... + + + + + (scroll down for more) + + 0}> diff --git a/packages/opencode/src/cli/cmd/tui/util/pagination.ts b/packages/opencode/src/cli/cmd/tui/util/pagination.ts new file mode 100644 index 00000000000..ea45475c79c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/pagination.ts @@ -0,0 +1,51 @@ +import type { Message } from "@opencode-ai/sdk/v2" + +export const windowOldest = (messages: Message[], pinned?: string) => { + if (!pinned) return messages.at(0)?.id + for (const msg of messages) { + if (msg.id !== pinned) return msg.id + } + return undefined +} + +export const windowNewest = (messages: Message[], pinned?: string) => { + if (!pinned) return messages.at(-1)?.id + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i] + if (msg && msg.id !== pinned) return msg.id + } + return undefined +} + +export const evictFromStart = (messages: Message[], count: number, pinned?: string) => { + const evicted: Message[] = [] + if (count <= 0) return evicted + let index = 0 + while (index < messages.length && evicted.length < count) { + const msg = messages[index] + if (!msg) break + if (msg.id !== pinned) { + evicted.push(msg) + messages.splice(index, 1) + continue + } + index += 1 + } + return evicted +} + +export const evictFromEnd = (messages: Message[], count: number, pinned?: string) => { + const evicted: Message[] = [] + if (count <= 0) return evicted + let index = messages.length - 1 + while (index >= 0 && evicted.length < count) { + const msg = messages[index] + if (!msg) break + if (msg.id !== pinned) { + evicted.push(msg) + messages.splice(index, 1) + } + index -= 1 + } + return evicted +} diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 3850376bdb4..39f41eeb2b4 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -4,6 +4,7 @@ import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" +import { Identifier } from "../../id/id" import { SessionPrompt } from "../../session/prompt" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" @@ -570,16 +571,97 @@ export const SessionRoutes = lazy(() => validator( "query", z.object({ - limit: z.coerce.number().optional(), + limit: z.coerce.number().int().min(0).optional(), + before: Identifier.schema("message").optional(), + after: Identifier.schema("message").optional(), + oldest: z.coerce.boolean().optional(), }), ), async (c) => { const query = c.req.valid("query") - const messages = await Session.messages({ - sessionID: c.req.valid("param").sessionID, - limit: query.limit, + if (query.before && query.after) { + return c.json({ error: "Cannot specify both 'before' and 'after'" }, 400) + } + if (query.oldest && (query.before || query.after)) { + return c.json({ error: "Cannot use 'oldest' with 'before' or 'after'" }, 400) + } + + const rawLimit = query.limit === 0 ? undefined : query.limit + const sessionID = c.req.valid("param").sessionID + + const usesCursor = !!(query.oldest || query.after || query.before) + + if (!usesCursor && rawLimit === undefined) { + const messages = await Session.messages({ sessionID }) + return c.json(messages) + } + + const pageLimit = usesCursor ? (rawLimit ? Math.min(rawLimit, 100) : 100) : (rawLimit ?? 100) + + if (query.oldest) { + const page = await Session.messages({ + sessionID, + limit: pageLimit + 1, + oldest: true, + }) + + if (page.length > pageLimit) { + const messages = page.slice(0, -1) + const last = messages.at(-1) + if (last) { + const url = new URL(c.req.url) + url.searchParams.delete("oldest") + url.searchParams.set("limit", pageLimit.toString()) + url.searchParams.set("after", last.info.id) + c.header("Link", `<${url.toString()}>; rel=\"next\"`) + } + return c.json(messages) + } + + return c.json(page) + } + + if (query.after) { + const page = await Session.messages({ + sessionID, + limit: pageLimit + 1, + after: query.after, + }) + + if (page.length > pageLimit) { + const messages = page.slice(0, -1) + const last = messages.at(-1) + if (last) { + const url = new URL(c.req.url) + url.searchParams.set("limit", pageLimit.toString()) + url.searchParams.set("after", last.info.id) + c.header("Link", `<${url.toString()}>; rel=\"next\"`) + } + return c.json(messages) + } + + return c.json(page) + } + + const page = await Session.messages({ + sessionID, + limit: pageLimit + 1, + before: query.before, }) - return c.json(messages) + + if (page.length > pageLimit) { + const messages = page.slice(1) + const first = messages.at(0) + if (first) { + const url = new URL(c.req.url) + url.searchParams.set("limit", pageLimit.toString()) + url.searchParams.set("before", first.info.id) + c.header("Link", `<${url.toString()}>; rel=\"prev\"`) + } + return c.json(messages) + } + + return c.json(page) }, ) .get( diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b81a21a57be..bbf8ce285fa 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,4 +1,5 @@ import { Slug } from "@opencode-ai/util/slug" +import { Binary } from "@opencode-ai/util/binary" import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" @@ -9,7 +10,6 @@ import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" - import { Storage } from "../storage/storage" import { Log } from "../util/log" import { MessageV2 } from "./message-v2" @@ -18,7 +18,6 @@ import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" import { Command } from "../command" import { Snapshot } from "@/snapshot" - import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" import { Global } from "@/global" @@ -303,13 +302,97 @@ export namespace Session { z.object({ sessionID: Identifier.schema("session"), limit: z.number().optional(), + before: Identifier.schema("message").optional(), + after: Identifier.schema("message").optional(), + oldest: z.boolean().optional(), }), async (input) => { - const result = [] as MessageV2.WithParts[] - for await (const msg of MessageV2.stream(input.sessionID)) { - if (input.limit && result.length >= input.limit) break - result.push(msg) + // Mutual exclusion validation (fail-fast before I/O) + if (input.before && input.after) { + throw new Error("Cannot specify both 'before' and 'after' cursors") + } + if (input.oldest && (input.before || input.after)) { + throw new Error("Cannot use 'oldest' with 'before' or 'after' cursors") } + + const limit = input.limit === 0 ? undefined : input.limit + + const list = await Storage.list(["message", input.sessionID]) + const ids = list.map((x) => x[2]).filter((x): x is string => typeof x === "string") + + const collect = async (start: number, step: 1 | -1) => { + const results = [] as MessageV2.WithParts[] + const failures = [] as unknown[] + const state = { attempted: 0, index: start } + const inRange = () => (step === 1 ? state.index < ids.length : state.index >= 0) + + while (inRange()) { + if (limit !== undefined && results.length >= limit) break + const remaining = limit !== undefined ? limit - results.length : 100 + const batchSize = limit !== undefined ? Math.min(remaining, 100) : 100 + const batchIds: string[] = [] + + while (batchIds.length < batchSize && inRange()) { + const id = ids[state.index] + state.index += step + if (id) batchIds.push(id) + } + + if (batchIds.length === 0) break + state.attempted += batchIds.length + + const settled = await Promise.allSettled( + batchIds.map((id) => MessageV2.get({ sessionID: input.sessionID, messageID: id })), + ) + + for (const item of settled) { + if (item.status === "fulfilled") { + results.push(item.value) + } else { + failures.push(item.reason) + } + } + } + + if (failures.length > 0) { + log.warn("pagination: skipped failed message fetches", { + count: failures.length, + sessionID: input.sessionID, + reasons: failures.map((r: any) => r?.message ?? "unknown"), + }) + } + + if (results.length === 0 && state.attempted > 0) { + throw new Error( + `Failed to fetch ${state.attempted} messages for session ${input.sessionID} (${results.length} succeeded)`, + ) + } + + return results + } + + // Handle oldest=true: iterate from oldest to newest + if (input.oldest) { + return collect(0, 1) + } + + if (input.after) { + const cursorIndex = Binary.lowerBound(ids, input.after) + if (ids[cursorIndex] !== input.after) { + log.warn("pagination: cursor not found in session", { cursor: input.after, sessionID: input.sessionID }) + } + const start = ids[cursorIndex] === input.after ? cursorIndex + 1 : cursorIndex + + return collect(start, 1) + } + + const cursorIndex = input.before ? Binary.lowerBound(ids, input.before) : -1 + if (input.before && ids[cursorIndex] !== input.before) { + log.warn("pagination: cursor not found in session", { cursor: input.before, sessionID: input.sessionID }) + } + const start = input.before ? cursorIndex - 1 : ids.length - 1 + + const result = await collect(start, -1) result.reverse() return result }, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 650fda6e949..a4c44c64b59 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -606,15 +606,33 @@ export namespace MessageV2 { ) } - export const stream = fn(Identifier.schema("session"), async function* (sessionID) { - const list = await Array.fromAsync(await Storage.list(["message", sessionID])) - for (let i = list.length - 1; i >= 0; i--) { - yield await get({ - sessionID, - messageID: list[i][2], - }) - } - }) + export const stream = fn( + z.union([ + Identifier.schema("session"), + z.object({ + sessionID: Identifier.schema("session"), + ascending: z.boolean().optional(), + }), + ]), + async function* (input) { + const sessionID = typeof input === "string" ? input : input.sessionID + const ascending = typeof input === "object" && input.ascending + + const list = await Array.fromAsync(await Storage.list(["message", sessionID])) + + if (ascending) { + // Oldest-first (chronological order) + for (let i = 0; i < list.length; i++) { + yield await get({ sessionID, messageID: list[i][2] }) + } + } else { + // Newest-first (default, for pagination loading older messages) + for (let i = list.length - 1; i >= 0; i--) { + yield await get({ sessionID, messageID: list[i][2] }) + } + } + }, + ) export const parts = fn(Identifier.schema("message"), async (messageID) => { const result = [] as MessageV2.Part[] diff --git a/packages/opencode/src/util/link-header.ts b/packages/opencode/src/util/link-header.ts new file mode 100644 index 00000000000..7ff5c0fb34d --- /dev/null +++ b/packages/opencode/src/util/link-header.ts @@ -0,0 +1,27 @@ +/** + * Parse RFC 8288 Link header into a map of rel -> URL + * @see https://www.rfc-editor.org/rfc/rfc8288 + */ +export function parseLinkHeader(header: string): Record { + if (!header) return {} + try { + const links: Record = {} + const parts = header.split(",") + for (const part of parts) { + const section = part.split(";") + if (section.length < 2) continue + const url = section[0].replace(/<(.*?)>/, "$1").trim() + + for (const attr of section.slice(1)) { + const match = attr.match(/rel=["']?(?[^"']+)["']?/) + if (match?.groups?.rel) { + links[match.groups.rel.trim()] = url + break + } + } + } + return links + } catch { + return {} + } +} diff --git a/packages/opencode/test/cli/tui/pagination-helpers.test.ts b/packages/opencode/test/cli/tui/pagination-helpers.test.ts new file mode 100644 index 00000000000..78fbf5b555a --- /dev/null +++ b/packages/opencode/test/cli/tui/pagination-helpers.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { evictFromEnd, evictFromStart, windowNewest, windowOldest } from "../../../src/cli/cmd/tui/util/pagination" +import type { Message } from "@opencode-ai/sdk/v2" + +const make = (ids: string[]) => + ids.map( + (id) => + ({ + id, + sessionID: "ses_test", + role: "user", + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) as Message, + ) + +describe("tui pagination helpers", () => { + test("window bounds skip pinned message", () => { + const messages = make(["m1", "m2", "m3", "m4"]) + expect(windowOldest(messages, "m1")).toBe("m2") + expect(windowNewest(messages, "m4")).toBe("m3") + }) + + test("evictFromStart skips pinned messages", () => { + const messages = make(["m1", "m2", "m3", "m4", "m5"]) + const evicted = evictFromStart(messages, 2, "m2") + expect(evicted.map((m) => m.id)).toEqual(["m1", "m3"]) + expect(messages.map((m) => m.id)).toEqual(["m2", "m4", "m5"]) + }) + + test("evictFromEnd skips pinned messages", () => { + const messages = make(["m1", "m2", "m3", "m4", "m5"]) + const evicted = evictFromEnd(messages, 2, "m4") + expect(evicted.map((m) => m.id)).toEqual(["m5", "m3"]) + expect(messages.map((m) => m.id)).toEqual(["m1", "m2", "m4"]) + }) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts new file mode 100644 index 00000000000..28e10cfea74 --- /dev/null +++ b/packages/opencode/test/server/session-messages.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Session } from "../../src/session" +import { Identifier } from "../../src/id/id" +import { Log } from "../../src/util/log" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +const TEST_TIMEOUT_MS = 30_000 + +describe("session.messages API", () => { + test( + "returns 400 when both before and after specified", + async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + const response = await app.request(`/session/${session.id}/message?before=msg_01ABC&after=msg_01XYZ`) + + expect(response.status).toBe(400) + const body = (await response.json()) as { error: string } + expect(body.error).toContain("Cannot specify both") + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test("includes Link header with rel=prev when more pages exist (before cursor)", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + // Create 5 messages + for (let i = 0; i < 5; i++) { + await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + } + + // Request with limit=2 (should have more) + const response = await app.request(`/session/${session.id}/message?limit=2`) + + expect(response.status).toBe(200) + const link = response.headers.get("Link") + expect(link).toContain('rel="prev"') + expect(link).toContain("before=") + }, + }) + }) + + test("includes Link header with rel=next when using after cursor with more pages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + // Create 5 messages + const ids: string[] = [] + for (let i = 0; i < 5; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + ids.push(msg.id) + } + + // Request after first message with limit=2 + const response = await app.request(`/session/${session.id}/message?after=${ids[0]}&limit=2`) + + expect(response.status).toBe(200) + const link = response.headers.get("Link") + expect(link).toContain('rel="next"') + expect(link).toContain("after=") + }, + }) + }) + + test("omits Link header when no more pages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + // Create 2 messages + for (let i = 0; i < 2; i++) { + await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + } + + // Request with limit=10 (more than available) + const response = await app.request(`/session/${session.id}/message?limit=10`) + + expect(response.status).toBe(200) + const link = response.headers.get("Link") + expect(link).toBeNull() + }, + }) + }) + + test("returns 400 when oldest used with before or after", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + const response1 = await app.request(`/session/${session.id}/message?oldest=true&before=msg_01ABC`) + expect(response1.status).toBe(400) + const body1 = (await response1.json()) as { error: string } + expect(body1.error).toContain("Cannot use 'oldest' with") + + const response2 = await app.request(`/session/${session.id}/message?oldest=true&after=msg_01XYZ`) + expect(response2.status).toBe(400) + const body2 = (await response2.json()) as { error: string } + expect(body2.error).toContain("Cannot use 'oldest' with") + }, + }) + }) + + test("oldest=true returns messages in ascending order with rel=next Link", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + const session = await Session.create({}) + + // Create 5 messages with small delay to ensure ordering + const ids: string[] = [] + for (let i = 0; i < 5; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "test", modelID: "test" }, + time: { created: Date.now() }, + }) + ids.push(msg.id) + } + + // Request oldest with limit=2 (should have more pages) + const response = await app.request(`/session/${session.id}/message?oldest=true&limit=2`) + + expect(response.status).toBe(200) + const messages = (await response.json()) as Array<{ info: { id: string } }> + expect(messages.length).toBe(2) + // Oldest messages should be first (ascending order) + expect(messages[0].info.id).toBe(ids[0]) + expect(messages[1].info.id).toBe(ids[1]) + + const link = response.headers.get("Link") + expect(link).toContain('rel="next"') + expect(link).toContain("after=") + expect(link).not.toContain("oldest=") // oldest param stripped on subsequent pages + }, + }) + }) +}) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts new file mode 100644 index 00000000000..368d22409cf --- /dev/null +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, test } from "bun:test" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Identifier } from "../../src/id/id" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +const TEST_TIMEOUT_MS = 30_000 + +describe("session messages pagination", () => { + test( + "should paginate messages correctly", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + const messageCount = 10 + const messageIds: string[] = [] + + // Create 10 messages + for (let i = 0; i < messageCount; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + // time is optional/handled by default, ULID handles ordering + time: { created: Date.now() }, + }) + messageIds.push(msg.id) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: msg.id, + sessionID, + type: "text", + text: `Message ${i}`, + }) + } + + // 1. Initial load (limit 3) -> should get last 3 (7, 8, 9) + const page1 = await Session.messages({ + sessionID, + limit: 3, + }) + expect(page1.length).toBe(3) + expect(page1[0].info.id).toBe(messageIds[7]) + expect(page1[2].info.id).toBe(messageIds[9]) + + // 2. Load before page1[0] (limit 3) -> should get 4, 5, 6 + const page2 = await Session.messages({ + sessionID, + limit: 3, + before: page1[0].info.id, + }) + expect(page2.length).toBe(3) + expect(page2[0].info.id).toBe(messageIds[4]) + expect(page2[2].info.id).toBe(messageIds[6]) + + // 3. Load before page2[0] (limit 3) -> should get 1, 2, 3 + const page3 = await Session.messages({ + sessionID, + limit: 3, + before: page2[0].info.id, + }) + expect(page3.length).toBe(3) + expect(page3[0].info.id).toBe(messageIds[1]) + expect(page3[2].info.id).toBe(messageIds[3]) + + // 4. Load before page3[0] (limit 3) -> should get 0 (and only 1 message) + const page4 = await Session.messages({ + sessionID, + limit: 3, + before: page3[0].info.id, + }) + expect(page4.length).toBe(1) + expect(page4[0].info.id).toBe(messageIds[0]) + + // 5. Load before page4[0] -> should be empty + const page5 = await Session.messages({ + sessionID, + limit: 3, + before: page4[0].info.id, + }) + expect(page5.length).toBe(0) + + // 6. Test boundary: exact match (before message 9, should get 0..8) + // Wait, 'before' filters out the cursor itself. + // If IDs are [0..9]. before=ids[9]. + // Should get ids[0..8]. Length 9. + const exact = await Session.messages({ + sessionID, + limit: 10, + before: messageIds[9], + }) + expect(exact.length).toBe(9) + expect(exact[8].info.id).toBe(messageIds[8]) + + // 7. Test boundary: unknown cursor (lexicographically larger) + const unknownFuture = "msg" + "z".repeat(26) + const pageFuture = await Session.messages({ + sessionID, + limit: 3, + before: unknownFuture, + }) + expect(pageFuture.length).toBe(3) + expect(pageFuture[2].info.id).toBe(messageIds[9]) + + // 8. Test boundary: unknown cursor (lexicographically smaller) + const unknownPast = "msg" + "0".repeat(26) + const pagePast = await Session.messages({ + sessionID, + limit: 3, + before: unknownPast, + }) + expect(pagePast.length).toBe(0) + + // 9. Test concurrent load + const [res1, res2] = await Promise.all([ + Session.messages({ sessionID, limit: 3, before: page1[0].info.id }), + Session.messages({ sessionID, limit: 3, before: page1[0].info.id }), + ]) + + expect(res1[0].info.id).toBe(res2[0].info.id) + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test( + "handles deleted message during pagination", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + const messageIds: string[] = [] + + // Create 10 messages + for (let i = 0; i < 10; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Date.now() }, + }) + messageIds.push(msg.id) + } + + // Get first page (messages 6-10) + const page1 = await Session.messages({ sessionID, limit: 5 }) + expect(page1.length).toBe(5) + expect(page1[4].info.id).toBe(messageIds[9]) // Last message is most recent + + // Delete message 3 (which would be in the next page, index 2) + await Session.removeMessage({ sessionID, messageID: messageIds[2] }) + + // Request next page with cursor from page1 + const page2 = await Session.messages({ sessionID, limit: 5, before: page1[0].info.id }) + + // Verify remaining messages are returned without error + // Should get 0, 1, 3, 4 (since 2 was deleted) = 4 messages + // OR 0, 1, 3, 4 + one more if available? No, limit applies to ID list which is stale? + // Storage.list is re-run, so index 2 is gone. + // IDs: [0, 1, 3, 4, 5, 6, 7, 8, 9] + // Cursor: before 5 (index 4 in new list) + // binaryLowerBound(5) -> index 4 + // start = 3 + // loop: 3, 2, 1, 0 -> IDs[3]=4, IDs[2]=3, IDs[1]=1, IDs[0]=0 + expect(page2.length).toBe(4) + expect(page2.map((m) => m.info.id)).toEqual([messageIds[0], messageIds[1], messageIds[3], messageIds[4]]) + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test( + "message IDs are lexicographically sorted (ULID invariant)", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const messageIds: string[] = [] + + for (let i = 0; i < 5; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: session.id, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Date.now() }, + }) + messageIds.push(msg.id) + } + + // Verify IDs are lexicographically sorted (ULID invariant for binary search) + for (let i = 1; i < messageIds.length; i++) { + expect(messageIds[i] > messageIds[i - 1]).toBe(true) + } + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test( + "after cursor returns messages after cursor (ascending)", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + const messageIds: string[] = [] + + for (let i = 0; i < 10; i++) { + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Date.now() }, + }) + messageIds.push(msg.id) + } + + // after=msg[2] should return msg[3], msg[4], msg[5] (limit 3) + const page1 = await Session.messages({ + sessionID, + limit: 3, + after: messageIds[2], + }) + expect(page1.length).toBe(3) + expect(page1[0].info.id).toBe(messageIds[3]) + expect(page1[1].info.id).toBe(messageIds[4]) + expect(page1[2].info.id).toBe(messageIds[5]) + + // after=msg[8] should return msg[9] only + const page2 = await Session.messages({ + sessionID, + limit: 3, + after: messageIds[8], + }) + expect(page2.length).toBe(1) + expect(page2[0].info.id).toBe(messageIds[9]) + + // after=msg[9] (last) should return empty + const page3 = await Session.messages({ + sessionID, + limit: 3, + after: messageIds[9], + }) + expect(page3.length).toBe(0) + }, + }) + }, + TEST_TIMEOUT_MS, + ) + + test( + "cannot specify both before and after cursors", + async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const sessionID = session.id + + const msg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Date.now() }, + }) + + await expect( + Session.messages({ + sessionID, + limit: 3, + before: msg.id, + after: msg.id, + }), + ).rejects.toThrow("Cannot specify both") + }, + }) + }, + TEST_TIMEOUT_MS, + ) +}) diff --git a/packages/opencode/test/util/parse-link-header.test.ts b/packages/opencode/test/util/parse-link-header.test.ts new file mode 100644 index 00000000000..b1e316f2119 --- /dev/null +++ b/packages/opencode/test/util/parse-link-header.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import { parseLinkHeader } from "../../src/util/link-header" + +describe("util.parseLinkHeader", () => { + test("returns empty object for empty string", () => { + expect(parseLinkHeader("")).toEqual({}) + }) + + test("returns empty object for undefined-ish input", () => { + expect(parseLinkHeader(undefined as unknown as string)).toEqual({}) + }) + + test("parses single link with rel", () => { + const header = '; rel="next"' + expect(parseLinkHeader(header)).toEqual({ + next: "https://api.example.com/items?page=2", + }) + }) + + test("parses multiple links", () => { + const header = + '; rel="prev", ; rel="next"' + expect(parseLinkHeader(header)).toEqual({ + prev: "https://api.example.com/items?page=1", + next: "https://api.example.com/items?page=3", + }) + }) + + test("handles unquoted rel values", () => { + const header = "; rel=next" + expect(parseLinkHeader(header)).toEqual({ + next: "https://example.com", + }) + }) + + test("handles single-quoted rel values", () => { + const header = "; rel='next'" + expect(parseLinkHeader(header)).toEqual({ + next: "https://example.com", + }) + }) + + test("ignores links without rel attribute", () => { + const header = "; type=text/html" + expect(parseLinkHeader(header)).toEqual({}) + }) + + test("handles malformed input gracefully", () => { + expect(parseLinkHeader("not a valid header")).toEqual({}) + expect(parseLinkHeader("<<>>;;")).toEqual({}) + }) + + test("handles extra whitespace", () => { + const header = ' ; rel="next" ' + expect(parseLinkHeader(header)).toEqual({ + next: "https://example.com", + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index d39dd2b3485..09da89296f8 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1330,6 +1330,9 @@ export class Session extends HeyApiClient { sessionID: string directory?: string limit?: number + before?: string + after?: string + oldest?: boolean }, options?: Options, ) { @@ -1341,6 +1344,9 @@ export class Session extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "limit" }, + { in: "query", key: "before" }, + { in: "query", key: "after" }, + { in: "query", key: "oldest" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2a63d721215..697efeb068b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3219,6 +3219,9 @@ export type SessionMessagesData = { query?: { directory?: string limit?: number + before?: string + after?: string + oldest?: boolean } url: "/session/{sessionID}/message" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index cf2f29d8589..35d8ee562ec 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2092,7 +2092,32 @@ "in": "query", "name": "limit", "schema": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + { + "in": "query", + "name": "before", + "schema": { + "type": "string", + "pattern": "^msg.*" + } + }, + { + "in": "query", + "name": "after", + "schema": { + "type": "string", + "pattern": "^msg.*" + } + }, + { + "in": "query", + "name": "oldest", + "schema": { + "type": "boolean" } } ], diff --git a/packages/util/src/binary.ts b/packages/util/src/binary.ts index 3d8f61851ae..048b6bc371b 100644 --- a/packages/util/src/binary.ts +++ b/packages/util/src/binary.ts @@ -38,4 +38,19 @@ export namespace Binary { array.splice(left, 0, item) return array } + + /** + * Find the first index where array[index] >= target (lower bound). + * For string arrays ordered lexicographically (e.g., ULIDs). + */ + export function lowerBound(array: string[], target: string): number { + let left = 0 + let right = array.length + while (left < right) { + const mid = (left + right) >>> 1 + if (array[mid] < target) left = mid + 1 + else right = mid + } + return left + } }