Skip to content
Merged
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
50 changes: 41 additions & 9 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -32,6 +32,7 @@ import {
CompactionConflictError,
ForkEmptyError,
InboxConflictError,
InvalidListCursorError,
MessageDecodeError,
MessageNotFoundError,
NotFoundError,
Expand Down Expand Up @@ -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
Expand All @@ -103,6 +106,7 @@ export {
BusyError,
CompactionConflictError,
InboxConflictError,
InvalidListCursorError,
MessageDecodeError,
MessageNotFoundError,
NotFoundError,
Expand All @@ -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<SessionSchema.Info, NotFoundError>
readonly fork: (
input: ForkInput,
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/session/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Session.
sessionID: SessionSchema.ID,
}) {}

export class InvalidListCursorError extends Schema.TaggedError<InvalidListCursorError>()(
"Session.InvalidListCursorError",
{},
) {
override get message() {
return "Invalid cursor"
}
}

export class MessageNotFoundError extends Schema.TaggedError<MessageNotFoundError>()("Session.MessageNotFoundError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
Expand Down
72 changes: 72 additions & 0 deletions packages/core/test/session-list.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}),
)
})
48 changes: 8 additions & 40 deletions packages/server/src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` })
}
Expand Down Expand Up @@ -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(
Expand Down
Loading