Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const MessagesQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
before: Schema.optional(Schema.String),
after: Schema.optional(Schema.String),
})
export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Database } from "@opencode-ai/core/database/database"
import { Agent } from "@/agent/agent"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { EventV2Bridge } from "@/event-v2-bridge"
Expand Down Expand Up @@ -48,6 +49,7 @@ const tryParseJson = (text: string) =>
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const database = yield* Database.Service
const shareSvc = yield* SessionShare.Service
const promptSvc = yield* SessionPrompt.Service
const revertSvc = yield* SessionRevert.Service
Expand Down Expand Up @@ -107,11 +109,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
query: typeof MessagesQuery.Type
}) {
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
if ((ctx.query.before || ctx.query.after) && ctx.query.limit === undefined)
return yield* new HttpApiError.BadRequest({})
if (ctx.query.before && ctx.query.after) return yield* new HttpApiError.BadRequest({})
const cursor = ctx.query.before ?? ctx.query.after
if (cursor) {
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
try: () => MessageV2.cursor.decode(cursor),
catch: () => new HttpApiError.BadRequest({}),
})
}
Expand All @@ -125,7 +129,8 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
}),
after: ctx.query.after,
}).pipe(Effect.provideService(Database.Service, database)),
)
if (!page.cursor) return page.items

Expand All @@ -134,7 +139,8 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
// header echoes the real origin instead of a hard-coded localhost.
const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost"))
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
const direction = ctx.query.after ? "after" : "before"
url.searchParams.set(direction, page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Expand Down
31 changes: 23 additions & 8 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { desc } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { lt } from "drizzle-orm"
import { gt } from "drizzle-orm"
import { asc } from "drizzle-orm"
import { or } from "drizzle-orm"
import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql"
import { ProviderError } from "@/provider/error"
Expand Down Expand Up @@ -95,11 +97,15 @@ const part = (row: typeof PartTable.$inferSelect) =>
const older = (row: Cursor) =>
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))

function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) {
const newer = (row: Cursor) =>
or(gt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), gt(MessageTable.id, row.id)))

function hydrate(rows: (typeof MessageTable.$inferSelect)[]) {
const ids = rows.map((row) => row.id)
const partByMessage = new Map<string, Part[]>()
return Effect.gen(function* () {
if (ids.length > 0) {
const { db } = yield* Database.Service
const partRows = yield* db
.select()
.from(PartTable)
Expand Down Expand Up @@ -426,17 +432,26 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {
sessionID: SessionID
limit: number
before?: string
after?: string
}) {
const { db } = yield* Database.Service
if (input.before && input.after)
throw new Error("page: only one of `before` or `after` may be provided")
const before = input.before ? cursor.decode(input.before) : undefined
const after = input.after ? cursor.decode(input.after) : undefined
const where = before
? and(eq(MessageTable.session_id, input.sessionID), older(before))
: eq(MessageTable.session_id, input.sessionID)
: after
? and(eq(MessageTable.session_id, input.sessionID), newer(after))
: eq(MessageTable.session_id, input.sessionID)
const { db } = yield* Database.Service
const rows = yield* db
.select()
.from(MessageTable)
.where(where)
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))
.orderBy(
after ? asc(MessageTable.time_created) : desc(MessageTable.time_created),
after ? asc(MessageTable.id) : desc(MessageTable.id),
)
.limit(input.limit + 1)
.all()
.pipe(Effect.orDie)
Expand All @@ -456,13 +471,13 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {

const more = rows.length > input.limit
const slice = more ? rows.slice(0, input.limit) : rows
const items = yield* hydrate(db, slice)
items.reverse()
const tail = slice.at(-1)
const items = yield* hydrate(slice)
if (!after) items.reverse()
const cursorRow = slice.at(-1)
return {
items,
more,
cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined,
cursor: more && cursorRow ? cursor.encode({ id: cursorRow.id, time: cursorRow.time_created }) : undefined,
}
})

Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/session/messages-pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,46 @@ describe("MessageV2.page", () => {
}),
)

it.instance("pages forward with after cursor", () =>
withSession(({ sessionID }) =>
Effect.gen(function* () {
const ids = yield* fill(sessionID, 6)

// Anchor at "before everything": all messages are newer than time 0
const anchor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 })

const a = yield* MessageV2.page({ sessionID, limit: 2, after: anchor })
expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2))
expect(a.more).toBe(true)
expect(a.cursor).toBeTruthy()

const b = yield* MessageV2.page({ sessionID, limit: 2, after: a.cursor! })
expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(2, 4))
expect(b.more).toBe(true)
expect(b.cursor).toBeTruthy()

const c = yield* MessageV2.page({ sessionID, limit: 2, after: b.cursor! })
expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(4, 6))
expect(c.more).toBe(false)
expect(c.cursor).toBeUndefined()
}),
),
)

it.instance("rejects requests with both before and after", () =>
withSession(({ sessionID }) =>
Effect.gen(function* () {
yield* fill(sessionID, 2)
const dummyCursor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 })

const exit = yield* Effect.exit(
MessageV2.page({ sessionID, limit: 2, before: dummyCursor, after: dummyCursor }),
)
expect(exit._tag).toBe("Failure")
}),
),
)

it.instance("large limit returns all messages without cursor", () =>
withSession(({ sessionID }) =>
Effect.gen(function* () {
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2556,6 +2556,8 @@ export type SessionMessagesData = {
query?: {
directory?: string
limit?: number
before?: string
after?: string
}
url: "/session/{id}/message"
}
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3710,6 +3710,7 @@ export class Session2 extends HeyApiClient {
workspace?: string
limit?: number
before?: string
after?: string
},
options?: Options<never, ThrowOnError>,
) {
Expand All @@ -3723,6 +3724,7 @@ export class Session2 extends HeyApiClient {
{ in: "query", key: "workspace" },
{ in: "query", key: "limit" },
{ in: "query", key: "before" },
{ in: "query", key: "after" },
],
},
],
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9758,6 +9758,7 @@ export type SessionMessagesData = {
workspace?: string
limit?: number
before?: string
after?: string
}
url: "/session/{sessionID}/message"
}
Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6132,6 +6132,14 @@
"type": "string"
},
"required": false
},
{
"name": "after",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
Expand Down
Loading
Loading