diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index eae82e1c6dd..0e24882df13 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,9 +1,9 @@ export * as Session from "./session.js" export * from "./session/schema.js" -import { Effect, Layer, Schema, Context, Stream } from "effect" +import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect" import { LLMClient } from "@opencode/ai" -import { ListAnchor, ListCursor } from "@opencode/schema/session" +import { ListAnchor, ListCursor, ListFilter } from "@opencode/schema/session" import { and, desc, eq } from "drizzle-orm" import { Project } from "./project.js" import { Model } from "@opencode/schema/model" @@ -32,6 +32,7 @@ import { CompactionConflictError, ForkEmptyError, InboxConflictError, + InvalidListCursorError, MessageDecodeError, MessageNotFoundError, NotFoundError, @@ -80,6 +81,8 @@ export { ListAnchor, ListCursor } export const ListInput = SessionStore.ListInput export type ListInput = SessionStore.ListInput +const DefaultListLimit = 50 + type CreateBaseInput = { id?: SessionSchema.ID title?: string @@ -103,6 +106,7 @@ export { BusyError, CompactionConflictError, InboxConflictError, + InvalidListCursorError, MessageDecodeError, MessageNotFoundError, NotFoundError, @@ -116,9 +120,14 @@ export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnav export { TurnRangeError } export interface Interface { - readonly list: (input?: ListInput) => Effect.Effect<{ - readonly data: SessionSchema.Info[] - }> + /** A cursor carries the filters of the request that produced it, so filters passed with a cursor are ignored. */ + readonly list: (input?: ListFilter & { readonly limit?: number; readonly cursor?: ListCursor }) => Effect.Effect< + { + readonly data: SessionSchema.Info[] + readonly cursor: { readonly previous?: ListCursor; readonly next?: ListCursor } + }, + InvalidListCursorError + > readonly create: (input: CreateInput) => Effect.Effect readonly fork: ( input: ForkInput, @@ -369,14 +378,37 @@ const layer = Layer.effect( yield* execution.interrupt(sessionID) yield* execution.awaitIdle(sessionID) yield* transport.close(sessionID) - const children = yield* result.list({ parentID: sessionID }) - yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true }) + const children = yield* store.list({ parentID: sessionID }) + yield* Effect.forEach(children, (child) => result.remove(child.id), { concurrency: 1, discard: true }) yield* environments.clear(sessionID) yield* bus.publish(SessionEvent.Deleted, { sessionID }) yield* bus.remove(sessionID) }), - list: Effect.fn("Session.list")(function* (input) { - return { data: yield* store.list(input) } + list: Effect.fn("Session.list")(function* (input = {}) { + const filter = + input.cursor === undefined + ? input + : yield* ListCursor.parse(input.cursor).pipe(Effect.mapError(() => new InvalidListCursorError())) + const data = yield* store.list({ ...filter, limit: input.limit ?? DefaultListLimit }) + const first = data[0] + const last = data.at(-1) + return { + data, + cursor: { + previous: first + ? ListCursor.make({ + ...filter, + anchor: { id: first.id, time: DateTime.toEpochMillis(first.time.updated), direction: "previous" }, + }) + : undefined, + next: last + ? ListCursor.make({ + ...filter, + anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" }, + }) + : undefined, + }, + } }), messages: Effect.fn("Session.messages")(function* (input) { yield* result.get(input.sessionID) diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 60d6bada1b3..5d0fb7754ac 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -11,6 +11,15 @@ export class NotFoundError extends Schema.TaggedError()("Session. sessionID: SessionSchema.ID, }) {} +export class InvalidListCursorError extends Schema.TaggedError()( + "Session.InvalidListCursorError", + {}, +) { + override get message() { + return "Invalid cursor" + } +} + export class MessageNotFoundError extends Schema.TaggedError()("Session.MessageNotFoundError", { sessionID: SessionSchema.ID, messageID: SessionMessage.ID, diff --git a/packages/core/test/session-list.test.ts b/packages/core/test/session-list.test.ts new file mode 100644 index 00000000000..1138130ab6f --- /dev/null +++ b/packages/core/test/session-list.test.ts @@ -0,0 +1,72 @@ +import { describe, expect } from "bun:test" +import { Bus } from "@opencode/core/bus" +import { Database } from "@opencode/core/database/database" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { Location } from "@opencode/core/location" +import { Project } from "@opencode/core/project" +import { AbsolutePath } from "@opencode/core/schema" +import { Session } from "@opencode/core/session" +import { SessionExecution } from "@opencode/core/session/execution" +import { SessionProjector } from "@opencode/core/session/projector" +import { SessionStore } from "@opencode/core/session/store" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { Effect, Schema } from "effect" +import { testEffect } from "./lib/effect" +import { globalProjectNode } from "./lib/project" + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), + [ + Bus.node.replace(Bus.configured({ persist: true })), + Project.node.replace(globalProjectNode), + SessionExecution.node.replace(SessionExecution.noopLayer), + ], + ), +) +const project = Location.Ref.make({ directory: AbsolutePath.make("/project") }) +const other = Location.Ref.make({ directory: AbsolutePath.make("/other") }) + +describe("Session.list", () => { + it.effect("pages through the filtered store results in both directions", () => + Effect.gen(function* () { + const session = yield* Session.Service + yield* Effect.forEach(["match one", "match two", "match three"], (title) => + session.create({ location: project, title }), + ) + yield* session.create({ location: project, title: "different" }) + yield* session.create({ location: other, title: "match elsewhere" }) + const filter = { directory: project.directory, search: "match", order: "asc" as const } + const store = yield* SessionStore.Service + const expected = (yield* store.list(filter)).map((item) => item.id) + + const first = yield* session.list({ ...filter, limit: 2 }) + const second = yield* session.list({ cursor: first.cursor.next, limit: 2 }) + const back = yield* session.list({ cursor: second.cursor.previous, limit: 2 }) + const end = yield* session.list({ cursor: second.cursor.next, limit: 2 }) + + expect(expected).toHaveLength(3) + expect([...first.data, ...second.data].map((item) => item.id)).toEqual(expected) + expect(back.data.map((item) => item.id)).toEqual(expected.slice(0, 2)) + expect(end).toEqual({ data: [], cursor: { previous: undefined, next: undefined } }) + }), + ) + + it.effect("returns 50 sessions when no limit is given", () => + Effect.gen(function* () { + const session = yield* Session.Service + yield* Effect.forEach(Array.from({ length: 51 }), () => session.create({ location: project }), { discard: true }) + + expect((yield* session.list()).data).toHaveLength(50) + }), + ) + + it.effect("rejects an invalid cursor", () => + Effect.gen(function* () { + const session = yield* Session.Service + const error = yield* session.list({ cursor: Schema.decodeSync(Session.ListCursor)("invalid") }).pipe(Effect.flip) + + expect(error).toBeInstanceOf(Session.InvalidListCursorError) + }), + ) +}) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 79eb77a803d..3119adb48d9 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -25,8 +25,6 @@ import { AbsolutePath } from "@opencode/core/schema" import { locationErrors } from "../location" import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error" -const DefaultSessionsLimit = 50 - function missingForm(id: Form.ID) { return new FormNotFoundError({ id, message: `Form not found: ${id}` }) } @@ -60,44 +58,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.list", Effect.fn(function* (ctx) { - const query = - ctx.query.cursor !== undefined - ? yield* Session.ListCursor.parse(ctx.query.cursor).pipe( - Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), - ) - : ctx.query - const page = yield* session.list({ - ...query, - limit: ctx.query.limit ?? DefaultSessionsLimit, - }) - const sessions = page.data - const first = sessions[0] - const last = sessions.at(-1) - return { - data: sessions, - cursor: { - previous: first - ? Session.ListCursor.make({ - ...query, - anchor: { - id: first.id, - time: DateTime.toEpochMillis(first.time.updated), - direction: "previous", - }, - }) - : undefined, - next: last - ? Session.ListCursor.make({ - ...query, - anchor: { - id: last.id, - time: DateTime.toEpochMillis(last.time.updated), - direction: "next", - }, - }) - : undefined, - }, - } + return yield* session + .list(ctx.query) + .pipe( + Effect.catchTag( + "Session.InvalidListCursorError", + (error) => new InvalidCursorError({ message: error.message }), + ), + ) }), ) .handle(