Skip to content

Commit fb486a0

Browse files
committed
api: add after cursor for forward pagination on /session/:id/message
The v1 messages endpoint previously only supported backward pagination via the `before` cursor. Adding an `after` cursor enables the TUI to recover messages that have been evicted from the in-memory window when the user scrolls back toward the live tail. - MessageV2.page accepts an optional `after` cursor (mutually exclusive with `before`); when set, it returns the next page in ascending chronological order using a `newer()` predicate. - Handler validates that only one of `before`/`after` is supplied, decodes the cursor, and emits the `Link` / `X-Next-Cursor` headers with the correct direction parameter. - MessagesQuery schema gains an optional `after` field. - Tests cover forward pagination across multiple pages and the before+after-together rejection path.
1 parent 0a139ab commit fb486a0

4 files changed

Lines changed: 78 additions & 8 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/groups/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const MessagesQuery = Schema.Struct({
4141
...WorkspaceRoutingQueryFields,
4242
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
4343
before: Schema.optional(Schema.String),
44+
after: Schema.optional(Schema.String),
4445
})
4546
export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
4647
export const UpdatePayload = Schema.Struct({

packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,23 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
9494
params: { sessionID: SessionID }
9595
query: typeof MessagesQuery.Type
9696
}) {
97-
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
97+
if ((ctx.query.before || ctx.query.after) && ctx.query.limit === undefined)
98+
return yield* new HttpApiError.BadRequest({})
99+
if (ctx.query.before && ctx.query.after) return yield* new HttpApiError.BadRequest({})
98100
if (ctx.query.before) {
99101
const before = ctx.query.before
100102
yield* Effect.try({
101103
try: () => MessageV2.cursor.decode(before),
102104
catch: () => new HttpApiError.BadRequest({}),
103105
})
104106
}
107+
if (ctx.query.after) {
108+
const after = ctx.query.after
109+
yield* Effect.try({
110+
try: () => MessageV2.cursor.decode(after),
111+
catch: () => new HttpApiError.BadRequest({}),
112+
})
113+
}
105114
yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID))
106115
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
107116
return yield* session.messages({ sessionID: ctx.params.sessionID })
@@ -111,6 +120,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
111120
sessionID: ctx.params.sessionID,
112121
limit: ctx.query.limit,
113122
before: ctx.query.before,
123+
after: ctx.query.after,
114124
})
115125
if (!page.cursor) return page.items
116126

@@ -119,7 +129,8 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
119129
// header echoes the real origin instead of a hard-coded localhost.
120130
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
121131
url.searchParams.set("limit", ctx.query.limit.toString())
122-
url.searchParams.set("before", page.cursor)
132+
const direction = ctx.query.after ? "after" : "before"
133+
url.searchParams.set(direction, page.cursor)
123134
return HttpServerResponse.jsonUnsafe(page.items, {
124135
headers: {
125136
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",

packages/opencode/src/session/message-v2.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { desc } from "drizzle-orm"
1212
import { eq } from "drizzle-orm"
1313
import { inArray } from "drizzle-orm"
1414
import { lt } from "drizzle-orm"
15+
import { gt } from "drizzle-orm"
16+
import { asc } from "drizzle-orm"
1517
import { or } from "drizzle-orm"
1618
import { MessageTable, PartTable, SessionTable } from "./session.sql"
1719
import * as ProviderError from "@/provider/error"
@@ -595,6 +597,9 @@ const part = (row: typeof PartTable.$inferSelect) =>
595597
const older = (row: Cursor) =>
596598
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
597599

600+
const newer = (row: Cursor) =>
601+
or(gt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), gt(MessageTable.id, row.id)))
602+
598603
function hydrate(rows: (typeof MessageTable.$inferSelect)[]) {
599604
const ids = rows.map((row) => row.id)
600605
const partByMessage = new Map<string, Part[]>()
@@ -919,17 +924,25 @@ export function toModelMessages(
919924
return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer)))
920925
}
921926

922-
export function page(input: { sessionID: SessionID; limit: number; before?: string }) {
927+
export function page(input: { sessionID: SessionID; limit: number; before?: string; after?: string }) {
928+
if (input.before && input.after)
929+
throw new Error("page: only one of `before` or `after` may be provided")
923930
const before = input.before ? cursor.decode(input.before) : undefined
931+
const after = input.after ? cursor.decode(input.after) : undefined
924932
const where = before
925933
? and(eq(MessageTable.session_id, input.sessionID), older(before))
926-
: eq(MessageTable.session_id, input.sessionID)
934+
: after
935+
? and(eq(MessageTable.session_id, input.sessionID), newer(after))
936+
: eq(MessageTable.session_id, input.sessionID)
927937
const rows = Database.use((db) =>
928938
db
929939
.select()
930940
.from(MessageTable)
931941
.where(where)
932-
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))
942+
.orderBy(
943+
after ? asc(MessageTable.time_created) : desc(MessageTable.time_created),
944+
after ? asc(MessageTable.id) : desc(MessageTable.id),
945+
)
933946
.limit(input.limit + 1)
934947
.all(),
935948
)
@@ -947,12 +960,12 @@ export function page(input: { sessionID: SessionID; limit: number; before?: stri
947960
const more = rows.length > input.limit
948961
const slice = more ? rows.slice(0, input.limit) : rows
949962
const items = hydrate(slice)
950-
items.reverse()
951-
const tail = slice.at(-1)
963+
if (!after) items.reverse()
964+
const cursorRow = slice.at(-1)
952965
return {
953966
items,
954967
more,
955-
cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined,
968+
cursor: more && cursorRow ? cursor.encode({ id: cursorRow.id, time: cursorRow.time_created }) : undefined,
956969
}
957970
}
958971

packages/opencode/test/session/messages-pagination.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,51 @@ describe("MessageV2.page", () => {
286286
}),
287287
)
288288

289+
test("pages forward with after cursor", async () => {
290+
await WithInstance.provide({
291+
directory: root,
292+
fn: async () => {
293+
const session = await svc.create({})
294+
const ids = await fill(session.id, 6)
295+
296+
// Anchor at "before everything": all messages are newer than time 0
297+
const anchor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 })
298+
299+
const a = MessageV2.page({ sessionID: session.id, limit: 2, after: anchor })
300+
expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
301+
expect(a.more).toBe(true)
302+
expect(a.cursor).toBeTruthy()
303+
304+
const b = MessageV2.page({ sessionID: session.id, limit: 2, after: a.cursor! })
305+
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(2, 4))
306+
expect(b.more).toBe(true)
307+
expect(b.cursor).toBeTruthy()
308+
309+
const c = MessageV2.page({ sessionID: session.id, limit: 2, after: b.cursor! })
310+
expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(4, 6))
311+
expect(c.more).toBe(false)
312+
expect(c.cursor).toBeUndefined()
313+
314+
await svc.remove(session.id)
315+
},
316+
})
317+
})
318+
319+
test("rejects requests with both before and after", async () => {
320+
await WithInstance.provide({
321+
directory: root,
322+
fn: async () => {
323+
const session = await svc.create({})
324+
await fill(session.id, 2)
325+
const dummyCursor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 })
326+
expect(() =>
327+
MessageV2.page({ sessionID: session.id, limit: 2, before: dummyCursor, after: dummyCursor }),
328+
).toThrow()
329+
await svc.remove(session.id)
330+
},
331+
})
332+
})
333+
289334
it.instance("large limit returns all messages without cursor", () =>
290335
withSession(({ sessionID }) =>
291336
Effect.gen(function* () {

0 commit comments

Comments
 (0)