Skip to content

Commit 965e74e

Browse files
committed
core: simplify message history pagination with unified cursor API
Replace separate before/after query parameters with a single cursor that carries direction info. Chat clients can now use 'start' or 'end' keywords to jump to the beginning or newest messages, and navigate history with a single cursor parameter instead of managing multiple pagination states.
1 parent f99c69b commit 965e74e

4 files changed

Lines changed: 59 additions & 81 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { SessionApi } from "./groups/session"
1919
import { SyncApi } from "./groups/sync"
2020
import { TuiApi } from "./groups/tui"
2121
import { WorkspaceApi } from "./groups/workspace"
22-
import { V2Api } from "./v2"
22+
import { V2Api } from "./groups/v2"
2323

2424
// SSE event schemas built from the same BusEvent/SyncEvent registries that
2525
// the Hono spec uses, so both specs emit identical Event/SyncEvent components.

packages/opencode/src/server/routes/instance/httpapi/v2.ts renamed to packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Prompt } from "@/v2/session-prompt"
44
import { SessionV2 } from "@/v2/session"
55
import { Schema } from "effect"
66
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
7-
import { Authorization } from "./middleware/authorization"
7+
import { Authorization } from "../middleware/authorization"
88

99
export const V2Api = HttpApi.make("v2")
1010
.add(
@@ -23,31 +23,25 @@ export const V2Api = HttpApi.make("v2")
2323
description:
2424
"Maximum number of messages to return. When omitted, the endpoint returns its default page size. Use limit without a cursor to fetch the newest page for chat history.",
2525
}),
26-
before: Schema.optional(Schema.String).annotate({
26+
cursor: Schema.optional(Schema.String).annotate({
2727
description:
28-
"Opaque pagination cursor for the item at the start of the current window. Returns messages older than this cursor. Mutually exclusive with after.",
29-
}),
30-
after: Schema.optional(Schema.String).annotate({
31-
description:
32-
"Opaque pagination cursor for the item at the end of the current window. Returns messages newer than this cursor. Mutually exclusive with before.",
33-
}),
34-
from: Schema.optional(Schema.Literal("start")).annotate({
35-
description:
36-
"Start from the beginning of session history instead of the newest messages. Mutually exclusive with before and after.",
28+
"Opaque pagination cursor returned as before or after in the previous response. The cursor encodes whether to fetch older or newer messages. Use start to read from the beginning or end to read from the latest messages; end is the default.",
3729
}),
3830
}).annotate({ identifier: "V2SessionMessagesQuery" }),
3931
success: Schema.Struct({
4032
items: Schema.Array(SessionMessage.Message),
41-
before: Schema.String.pipe(Schema.optional),
42-
after: Schema.String.pipe(Schema.optional),
33+
cursor: Schema.Struct({
34+
before: Schema.String.pipe(Schema.optional),
35+
after: Schema.String.pipe(Schema.optional),
36+
}),
4337
}).annotate({ identifier: "V2SessionMessagesResponse" }),
4438
error: HttpApiError.BadRequest,
4539
}).annotateMerge(
4640
OpenApi.annotations({
4741
identifier: "v2.session.messages",
4842
summary: "Get v2 session messages",
4943
description:
50-
"Retrieve projected v2 messages for a session. For chat clients, request the latest page with limit, page backward through older history with before, and catch up with newer messages using after.",
44+
"Retrieve projected v2 messages for a session. For chat clients, request the latest page with limit, page backward through older history with the before cursor, and catch up with newer messages using the after cursor.",
5145
}),
5246
),
5347
)

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

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ const DefaultMessagesLimit = 50
1010
const Cursor = Schema.Struct({
1111
id: SessionMessage.ID,
1212
time: Schema.Number,
13+
from: Schema.Union([Schema.Literal("start"), Schema.Literal("end")]),
1314
})
1415

1516
const decodeCursor = Schema.decodeUnknownSync(Cursor)
1617

1718
const cursor = {
18-
encode(message: SessionMessage.Message) {
19+
encode(message: SessionMessage.Message, from: "start" | "end") {
1920
return Buffer.from(
20-
JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created) }),
21+
JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), from }),
2122
).toString("base64url")
2223
},
2324
decode(input: string) {
@@ -33,29 +34,27 @@ export const v2Handlers = HttpApiBuilder.group(InstanceHttpApi, "v2", (handlers)
3334
.handle(
3435
"messages",
3536
Effect.fn(function* (ctx) {
36-
if (ctx.query.before && ctx.query.after) return yield* new HttpApiError.BadRequest({})
37-
if (ctx.query.from && (ctx.query.before || ctx.query.after)) return yield* new HttpApiError.BadRequest({})
3837
const decoded = yield* Effect.try({
39-
try: () => {
40-
return {
41-
before: ctx.query.before ? cursor.decode(ctx.query.before) : undefined,
42-
after: ctx.query.after ? cursor.decode(ctx.query.after) : undefined,
43-
}
44-
},
38+
try: () =>
39+
ctx.query.cursor && ctx.query.cursor !== "start" && ctx.query.cursor !== "end"
40+
? cursor.decode(ctx.query.cursor)
41+
: undefined,
4542
catch: () => new HttpApiError.BadRequest({}),
4643
})
4744
const messages = yield* session.messages({
4845
sessionID: ctx.params.sessionID,
4946
limit: ctx.query.limit ?? DefaultMessagesLimit,
50-
cursor: decoded.before ?? decoded.after,
51-
direction: decoded.after ? "after" : "before",
47+
from: decoded?.from ?? (ctx.query.cursor === "start" ? "start" : "end"),
48+
cursor: decoded ? { id: decoded.id, time: decoded.time } : undefined,
5249
})
5350
const oldest = messages[0]
5451
const newest = messages.at(-1)
5552
return {
5653
items: messages,
57-
before: oldest ? cursor.encode(oldest) : undefined,
58-
after: newest ? cursor.encode(newest) : undefined,
54+
cursor: {
55+
before: oldest ? cursor.encode(oldest, "end") : undefined,
56+
after: newest ? cursor.encode(newest, "start") : undefined,
57+
},
5958
}
6059
}),
6160
)

packages/opencode/src/v2/session.ts

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,16 @@ export type Delivery = Schema.Schema.Type<typeof Delivery>
1616

1717
export const DefaultDelivery = "immediate" satisfies Delivery
1818

19-
export type MessagesCursor = {
20-
id: SessionMessage.ID
21-
time: number
22-
}
23-
24-
export type MessagesInput = {
25-
sessionID: SessionID
26-
limit?: number
27-
cursor?: MessagesCursor
28-
direction?: "before" | "after"
29-
}
30-
31-
const older = (item: MessagesCursor) =>
32-
or(
33-
lt(SessionMessageTable.time_created, item.time),
34-
and(eq(SessionMessageTable.time_created, item.time), lt(SessionMessageTable.id, item.id)),
35-
)
36-
37-
const newer = (item: MessagesCursor) =>
38-
or(
39-
gt(SessionMessageTable.time_created, item.time),
40-
and(eq(SessionMessageTable.time_created, item.time), gt(SessionMessageTable.id, item.id)),
41-
)
42-
4319
export interface Interface {
44-
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Message[], never>
20+
readonly messages: (input: {
21+
sessionID: SessionID
22+
limit?: number
23+
from?: "start" | "end"
24+
cursor?: {
25+
id: SessionMessage.ID
26+
time: number
27+
}
28+
}) => Effect.Effect<SessionMessage.Message[], never>
4529
readonly prompt: (input: {
4630
id?: Event.ID
4731
sessionID: SessionID
@@ -64,44 +48,45 @@ export const layer = Layer.effect(
6448

6549
const result: Interface = {
6650
messages: Effect.fn("V2Session.messages")(function* (input) {
67-
if (input.limit === undefined) {
68-
const rows = Database.use((db) =>
69-
db
70-
.select()
71-
.from(SessionMessageTable)
72-
.where(eq(SessionMessageTable.session_id, input.sessionID))
73-
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
74-
.all(),
75-
)
76-
return rows.map((row) => decode(row))
77-
}
78-
79-
const limit = input.limit
80-
const direction = input.direction ?? "before"
81-
const where = input.cursor
82-
? and(
83-
eq(SessionMessageTable.session_id, input.sessionID),
84-
direction === "after" ? newer(input.cursor) : older(input.cursor),
85-
)
51+
const from = input.from ?? (input.limit === undefined && input.cursor === undefined ? "start" : "end")
52+
const boundary = input.cursor
53+
? from === "start"
54+
? or(
55+
gt(SessionMessageTable.time_created, input.cursor.time),
56+
and(
57+
eq(SessionMessageTable.time_created, input.cursor.time),
58+
gt(SessionMessageTable.id, input.cursor.id),
59+
),
60+
)
61+
: or(
62+
lt(SessionMessageTable.time_created, input.cursor.time),
63+
and(
64+
eq(SessionMessageTable.time_created, input.cursor.time),
65+
lt(SessionMessageTable.id, input.cursor.id),
66+
),
67+
)
68+
: undefined
69+
const where = boundary
70+
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
8671
: eq(SessionMessageTable.session_id, input.sessionID)
72+
8773
const rows = Database.use((db) => {
88-
if (direction === "after") {
89-
return db
74+
if (from === "start") {
75+
const query = db
9076
.select()
9177
.from(SessionMessageTable)
9278
.where(where)
9379
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
94-
.limit(limit)
95-
.all()
80+
return input.limit === undefined ? query.all() : query.limit(input.limit).all()
9681
}
97-
const ids = db
82+
const idsQuery = db
9883
.select({ id: SessionMessageTable.id })
9984
.from(SessionMessageTable)
10085
.where(where)
10186
.orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
102-
.limit(limit)
103-
.all()
104-
.map((row) => row.id)
87+
const ids = (input.limit === undefined ? idsQuery.all() : idsQuery.limit(input.limit).all()).map(
88+
(row) => row.id,
89+
)
10590
if (ids.length === 0) return []
10691
return db
10792
.select()

0 commit comments

Comments
 (0)