mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cac6d60ed | |||
| 903acdf0d4 |
@@ -4,6 +4,8 @@ import { Plugin } from "@opencode-ai/plugin/effect"
|
|||||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||||
|
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
|
||||||
|
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||||
import { App } from "../app"
|
import { App } from "../app"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { Agent } from "../agent"
|
import { Agent } from "../agent"
|
||||||
@@ -348,6 +350,40 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||||
}),
|
}),
|
||||||
get: (input) => runtime.session.get(input.sessionID),
|
get: (input) => runtime.session.get(input.sessionID),
|
||||||
|
list: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const query =
|
||||||
|
input?.cursor !== undefined
|
||||||
|
? yield* SessionsQuery.Cursor.parse(input.cursor).pipe(
|
||||||
|
Effect.mapError(() => new Error("Invalid cursor")),
|
||||||
|
)
|
||||||
|
: (input ?? {})
|
||||||
|
const page = yield* runtime.session.list({
|
||||||
|
...query,
|
||||||
|
workspaceID: query.workspace,
|
||||||
|
limit: input?.limit ?? SessionsQuery.DefaultLimit,
|
||||||
|
})
|
||||||
|
return { data: page.data, cursor: SessionsQuery.Cursor.page(query, page.data) }
|
||||||
|
}),
|
||||||
|
messages: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (input.cursor !== undefined && input.order !== undefined)
|
||||||
|
return yield* Effect.fail(new Error("Cursor cannot be combined with order"))
|
||||||
|
const decoded =
|
||||||
|
input.cursor !== undefined
|
||||||
|
? yield* SessionMessagesCursor.parse(input.cursor).pipe(
|
||||||
|
Effect.mapError(() => new Error("Invalid cursor")),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const order = decoded?.order ?? input.order ?? "desc"
|
||||||
|
const data = yield* runtime.session.messages({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
limit: input.limit ?? SessionMessagesCursor.DefaultLimit,
|
||||||
|
order,
|
||||||
|
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||||
|
})
|
||||||
|
return { data, cursor: SessionMessagesCursor.page(data, order) }
|
||||||
|
}),
|
||||||
prompt: runtime.session.prompt,
|
prompt: runtime.session.prompt,
|
||||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||||
command: runtime.session.command,
|
command: runtime.session.command,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Interface {
|
|||||||
Session.Interface,
|
Session.Interface,
|
||||||
| "get"
|
| "get"
|
||||||
| "create"
|
| "create"
|
||||||
|
| "list"
|
||||||
| "messages"
|
| "messages"
|
||||||
| "prompt"
|
| "prompt"
|
||||||
| "generate"
|
| "generate"
|
||||||
@@ -58,6 +59,7 @@ export const layerWithCell = (cell: Cell) =>
|
|||||||
session: {
|
session: {
|
||||||
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
||||||
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
||||||
|
list: (input) => require(cell, (runtime) => runtime.session.list(input)),
|
||||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
||||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||||
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
|||||||
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
|
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
|
||||||
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
|
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
|
||||||
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
|
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
|
||||||
|
list: overrides.session?.list ?? (() => Effect.die("unused session.list")),
|
||||||
|
messages: overrides.session?.messages ?? (() => Effect.die("unused session.messages")),
|
||||||
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
|
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
|
||||||
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
|
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
|
||||||
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
|
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
import type { MessageApi, SessionApi } from "@opencode-ai/client/effect/api"
|
||||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||||
import type { Agent } from "@opencode-ai/schema/agent"
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
@@ -29,7 +29,9 @@ export interface SessionHooks {
|
|||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
SessionApi<unknown>,
|
SessionApi<unknown>,
|
||||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
"create" | "get" | "list" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||||
> & {
|
> & {
|
||||||
readonly hook: Hooks<SessionHooks>
|
readonly hook: Hooks<SessionHooks>
|
||||||
|
/** Read a session's projected message history, paginated like the HTTP message list endpoint. */
|
||||||
|
readonly messages: MessageApi<unknown>["list"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
|||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||||
import { Project } from "@opencode-ai/schema/project"
|
import { Project } from "@opencode-ai/schema/project"
|
||||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||||
|
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||||
@@ -29,76 +30,6 @@ import { Location } from "@opencode-ai/schema/location"
|
|||||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||||
|
|
||||||
const ParentIDFilter = Schema.Union([
|
|
||||||
Session.ID,
|
|
||||||
Schema.Null.pipe(
|
|
||||||
Schema.encodeTo(Schema.Literal("null"), {
|
|
||||||
decode: SchemaGetter.transform(() => null),
|
|
||||||
encode: SchemaGetter.transform(() => "null" as const),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]).annotate({
|
|
||||||
description: "Filter by parent session. Use null to return only root sessions.",
|
|
||||||
})
|
|
||||||
|
|
||||||
const SessionsQueryFields = {
|
|
||||||
workspace: Workspace.ID.pipe(Schema.optional),
|
|
||||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
|
||||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
|
||||||
}),
|
|
||||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
|
||||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
|
||||||
}),
|
|
||||||
search: Schema.optional(Schema.String),
|
|
||||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
|
||||||
}
|
|
||||||
|
|
||||||
const SessionsDirectoryQuery = Schema.Struct({
|
|
||||||
...SessionsQueryFields,
|
|
||||||
directory: AbsolutePath,
|
|
||||||
})
|
|
||||||
|
|
||||||
const SessionsProjectQuery = Schema.Struct({
|
|
||||||
...SessionsQueryFields,
|
|
||||||
project: Project.ID,
|
|
||||||
subpath: RelativePath.pipe(Schema.optional),
|
|
||||||
})
|
|
||||||
|
|
||||||
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
|
|
||||||
|
|
||||||
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
|
||||||
schema.mapFields((fields) => ({
|
|
||||||
...Struct.omit(fields, ["limit"]),
|
|
||||||
anchor: Session.ListAnchor,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const SessionsCursorInput = Schema.Union([
|
|
||||||
withCursor(SessionsDirectoryQuery),
|
|
||||||
withCursor(SessionsProjectQuery),
|
|
||||||
withCursor(SessionsAllQuery),
|
|
||||||
])
|
|
||||||
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
|
||||||
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
|
||||||
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
|
||||||
const invalidCursor = "Invalid cursor" as const
|
|
||||||
|
|
||||||
export const SessionsCursor = Schema.String.pipe(
|
|
||||||
Schema.brand("SessionsCursor"),
|
|
||||||
statics((schema) => {
|
|
||||||
const make = schema.make.bind(schema)
|
|
||||||
return {
|
|
||||||
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
|
|
||||||
parse: (input: string) =>
|
|
||||||
Effect.suspend(() => {
|
|
||||||
const result = Encoding.decodeBase64UrlString(input)
|
|
||||||
return Result.isFailure(result)
|
|
||||||
? Effect.fail(invalidCursor)
|
|
||||||
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
export type SessionsCursor = typeof SessionsCursor.Type
|
|
||||||
|
|
||||||
const SessionActive = Schema.Struct({
|
const SessionActive = Schema.Struct({
|
||||||
type: Schema.Literal("running"),
|
type: Schema.Literal("running"),
|
||||||
@@ -111,28 +42,17 @@ const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
|
||||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SessionsQuery = Schema.Struct({
|
|
||||||
...SessionsQueryFields,
|
|
||||||
directory: AbsolutePath.pipe(Schema.optional),
|
|
||||||
project: Project.ID.pipe(Schema.optional),
|
|
||||||
subpath: RelativePath.pipe(Schema.optional),
|
|
||||||
cursor: SessionsQueryCursor.pipe(Schema.optional),
|
|
||||||
}).annotate({ identifier: "SessionsQuery" })
|
|
||||||
|
|
||||||
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLocationMiddleware: Context.Key<I, S>) =>
|
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLocationMiddleware: Context.Key<I, S>) =>
|
||||||
HttpApiGroup.make("server.session")
|
HttpApiGroup.make("server.session")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("session.list", "/api/session", {
|
HttpApiEndpoint.get("session.list", "/api/session", {
|
||||||
query: SessionsQuery,
|
query: SessionsQuery.Query,
|
||||||
success: Schema.Struct({
|
success: Schema.Struct({
|
||||||
data: Schema.Array(Session.Info),
|
data: Schema.Array(Session.Info),
|
||||||
cursor: Schema.Struct({
|
cursor: Schema.Struct({
|
||||||
previous: SessionsCursor.pipe(Schema.optional),
|
previous: SessionsQuery.Cursor.pipe(Schema.optional),
|
||||||
next: SessionsCursor.pipe(Schema.optional),
|
next: SessionsQuery.Cursor.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
}).annotate({ identifier: "SessionsResponse" }),
|
}).annotate({ identifier: "SessionsResponse" }),
|
||||||
error: [InvalidCursorError, InvalidRequestError],
|
error: [InvalidCursorError, InvalidRequestError],
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { SessionsCursor } from "../src/groups/session.js"
|
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
|
||||||
|
|
||||||
describe("SessionsCursor", () => {
|
|
||||||
test("round trips without Node globals", async () => {
|
|
||||||
const input = {
|
|
||||||
workspace: undefined,
|
|
||||||
search: "protocol",
|
|
||||||
order: "desc" as const,
|
|
||||||
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
|
|
||||||
}
|
|
||||||
const cursor = SessionsCursor.make(input)
|
|
||||||
|
|
||||||
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export * as SessionMessagesCursor from "./session-messages-cursor.js"
|
||||||
|
|
||||||
|
import { Effect, Encoding, Result, Schema } from "effect"
|
||||||
|
import { SessionMessage } from "./session-message.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared session message pagination cursor. Lives in schema so the protocol
|
||||||
|
* message handler and the core plugin host paginate identically. The encoded
|
||||||
|
* cursor carries the page order, so a cursor cannot be combined with an
|
||||||
|
* explicit order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DefaultLimit = 50
|
||||||
|
|
||||||
|
export const Order = Schema.Literals(["asc", "desc"])
|
||||||
|
export type Order = typeof Order.Type
|
||||||
|
|
||||||
|
const Payload = Schema.Struct({
|
||||||
|
id: SessionMessage.ID,
|
||||||
|
order: Order,
|
||||||
|
direction: Schema.Literals(["previous", "next"]),
|
||||||
|
})
|
||||||
|
export type Payload = typeof Payload.Type
|
||||||
|
|
||||||
|
const PayloadJson = Schema.fromJsonString(Payload)
|
||||||
|
const encodePayload = Schema.encodeSync(PayloadJson)
|
||||||
|
const decodePayload = Schema.decodeUnknownEffect(PayloadJson)
|
||||||
|
const invalidCursor = "Invalid cursor" as const
|
||||||
|
|
||||||
|
export const make = (input: Payload) => Encoding.encodeBase64Url(encodePayload(input))
|
||||||
|
|
||||||
|
export const parse = (input: string) =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
const result = Encoding.decodeBase64UrlString(input)
|
||||||
|
return Result.isFailure(result)
|
||||||
|
? Effect.fail(invalidCursor)
|
||||||
|
: decodePayload(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** previous/next cursors for one returned page of messages. */
|
||||||
|
export const page = (data: ReadonlyArray<SessionMessage.Info>, order: Order) => {
|
||||||
|
const first = data[0]
|
||||||
|
const last = data.at(-1)
|
||||||
|
return {
|
||||||
|
previous: first ? make({ id: first.id, order, direction: "previous" }) : undefined,
|
||||||
|
next: last ? make({ id: last.id, order, direction: "next" }) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
export * as SessionsQuery from "./sessions-query.js"
|
||||||
|
|
||||||
|
import { DateTime, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||||
|
import { Project } from "./project.js"
|
||||||
|
import { AbsolutePath, PositiveInt, RelativePath, statics } from "./schema.js"
|
||||||
|
import { Session } from "./session.js"
|
||||||
|
import { Workspace } from "./workspace.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared session list query and cursor codec. Lives in schema so both the
|
||||||
|
* protocol session group and the core plugin host paginate identically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DefaultLimit = 50
|
||||||
|
|
||||||
|
const ParentIDFilter = Schema.Union([
|
||||||
|
Session.ID,
|
||||||
|
Schema.Null.pipe(
|
||||||
|
Schema.encodeTo(Schema.Literal("null"), {
|
||||||
|
decode: SchemaGetter.transform(() => null),
|
||||||
|
encode: SchemaGetter.transform(() => "null" as const),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]).annotate({
|
||||||
|
description: "Filter by parent session. Use null to return only root sessions.",
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Fields = {
|
||||||
|
workspace: Workspace.ID.pipe(Schema.optional),
|
||||||
|
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||||
|
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||||
|
}),
|
||||||
|
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||||
|
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||||
|
}),
|
||||||
|
search: Schema.optional(Schema.String),
|
||||||
|
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||||
|
}
|
||||||
|
|
||||||
|
const DirectoryQuery = Schema.Struct({
|
||||||
|
...Fields,
|
||||||
|
directory: AbsolutePath,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ProjectQuery = Schema.Struct({
|
||||||
|
...Fields,
|
||||||
|
project: Project.ID,
|
||||||
|
subpath: RelativePath.pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AllQuery = Schema.Struct(Fields)
|
||||||
|
|
||||||
|
const withAnchor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||||
|
schema.mapFields((fields) => ({
|
||||||
|
...Struct.omit(fields, ["limit"]),
|
||||||
|
anchor: Session.ListAnchor,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const CursorInput = Schema.Union([withAnchor(DirectoryQuery), withAnchor(ProjectQuery), withAnchor(AllQuery)])
|
||||||
|
const CursorJson = Schema.fromJsonString(CursorInput)
|
||||||
|
const encodeCursor = Schema.encodeSync(CursorJson)
|
||||||
|
const decodeCursor = Schema.decodeUnknownEffect(CursorJson)
|
||||||
|
const invalidCursor = "Invalid cursor" as const
|
||||||
|
|
||||||
|
type PageQuery = Omit<typeof Query.Type, "limit" | "cursor">
|
||||||
|
|
||||||
|
export const Cursor = Schema.String.pipe(
|
||||||
|
Schema.brand("SessionsCursor"),
|
||||||
|
statics((schema) => {
|
||||||
|
const make = schema.make.bind(schema)
|
||||||
|
const makeCursor = (input: typeof CursorInput.Type) => make(Encoding.encodeBase64Url(encodeCursor(input)))
|
||||||
|
return {
|
||||||
|
make: makeCursor,
|
||||||
|
parse: (input: string) =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
const result = Encoding.decodeBase64UrlString(input)
|
||||||
|
return Result.isFailure(result)
|
||||||
|
? Effect.fail(invalidCursor)
|
||||||
|
: decodeCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||||
|
}),
|
||||||
|
/**
|
||||||
|
* previous/next cursors for one returned page, re-anchoring the
|
||||||
|
* originating query at the page's first and last items.
|
||||||
|
*/
|
||||||
|
page: (query: PageQuery, data: ReadonlyArray<Session.Info>) => {
|
||||||
|
const first = data[0]
|
||||||
|
const last = data.at(-1)
|
||||||
|
return {
|
||||||
|
previous: first
|
||||||
|
? makeCursor({
|
||||||
|
...query,
|
||||||
|
anchor: {
|
||||||
|
id: first.id,
|
||||||
|
time: DateTime.toEpochMillis(first.time.updated),
|
||||||
|
direction: "previous",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
next: last
|
||||||
|
? makeCursor({
|
||||||
|
...query,
|
||||||
|
anchor: {
|
||||||
|
id: last.id,
|
||||||
|
time: DateTime.toEpochMillis(last.time.updated),
|
||||||
|
direction: "next",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
export type Cursor = typeof Cursor.Type
|
||||||
|
|
||||||
|
export const Query = Schema.Struct({
|
||||||
|
...Fields,
|
||||||
|
directory: AbsolutePath.pipe(Schema.optional),
|
||||||
|
project: Project.ID.pipe(Schema.optional),
|
||||||
|
subpath: RelativePath.pipe(Schema.optional),
|
||||||
|
cursor: Cursor.annotate({
|
||||||
|
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||||
|
}).pipe(Schema.optional),
|
||||||
|
}).annotate({ identifier: "SessionsQuery" })
|
||||||
|
export type Query = typeof Query.Type
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { SessionsQuery } from "../src/sessions-query.js"
|
||||||
|
import { SessionMessagesCursor } from "../src/session-messages-cursor.js"
|
||||||
|
import { SessionMessage } from "../src/session-message.js"
|
||||||
|
import { Session } from "../src/session.js"
|
||||||
|
|
||||||
|
describe("SessionsQuery.Cursor", () => {
|
||||||
|
test("round trips without Node globals", async () => {
|
||||||
|
const input = {
|
||||||
|
workspace: undefined,
|
||||||
|
search: "protocol",
|
||||||
|
order: "desc" as const,
|
||||||
|
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
|
||||||
|
}
|
||||||
|
const cursor = SessionsQuery.Cursor.make(input)
|
||||||
|
|
||||||
|
expect(await Effect.runPromise(SessionsQuery.Cursor.parse(cursor))).toEqual(input)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SessionMessagesCursor", () => {
|
||||||
|
test("round trips", async () => {
|
||||||
|
const input = {
|
||||||
|
id: SessionMessage.ID.make("msg_test"),
|
||||||
|
order: "desc" as const,
|
||||||
|
direction: "next" as const,
|
||||||
|
}
|
||||||
|
const cursor = SessionMessagesCursor.make(input)
|
||||||
|
|
||||||
|
expect(await Effect.runPromise(SessionMessagesCursor.parse(cursor))).toEqual(input)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,29 +1,10 @@
|
|||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
|
||||||
import { Session } from "@opencode-ai/core/session"
|
import { Session } from "@opencode-ai/core/session"
|
||||||
import { Effect, Schema } from "effect"
|
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
|
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
|
||||||
|
|
||||||
const DefaultMessagesLimit = 50
|
|
||||||
|
|
||||||
const Cursor = Schema.Struct({
|
|
||||||
id: SessionMessage.ID,
|
|
||||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
|
||||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
|
||||||
})
|
|
||||||
|
|
||||||
const decodeCursor = Schema.decodeUnknownSync(Cursor)
|
|
||||||
|
|
||||||
const cursor = {
|
|
||||||
encode(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") {
|
|
||||||
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
|
|
||||||
},
|
|
||||||
decode(input: string) {
|
|
||||||
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) =>
|
export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
@@ -33,15 +14,16 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
|||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
if (ctx.query.cursor && ctx.query.order !== undefined)
|
if (ctx.query.cursor && ctx.query.order !== undefined)
|
||||||
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
|
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
|
||||||
const decoded = yield* Effect.try({
|
const decoded = ctx.query.cursor
|
||||||
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
|
? yield* SessionMessagesCursor.parse(ctx.query.cursor).pipe(
|
||||||
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
|
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||||
})
|
)
|
||||||
|
: undefined
|
||||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||||
const messages = yield* session
|
const messages = yield* session
|
||||||
.messages({
|
.messages({
|
||||||
sessionID: ctx.params.sessionID,
|
sessionID: ctx.params.sessionID,
|
||||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
limit: ctx.query.limit ?? SessionMessagesCursor.DefaultLimit,
|
||||||
order,
|
order,
|
||||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||||
})
|
})
|
||||||
@@ -66,14 +48,9 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const first = messages[0]
|
|
||||||
const last = messages.at(-1)
|
|
||||||
return {
|
return {
|
||||||
data: messages,
|
data: messages,
|
||||||
cursor: {
|
cursor: SessionMessagesCursor.page(messages, order),
|
||||||
previous: first ? cursor.encode(first, order, "previous") : undefined,
|
|
||||||
next: last ? cursor.encode(last, order, "next") : undefined,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
|||||||
import { DateTime, Effect, Stream } from "effect"
|
import { DateTime, Effect, Stream } from "effect"
|
||||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||||
import { Api } from "../api"
|
import { Api } from "../api"
|
||||||
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||||
import {
|
import {
|
||||||
ConflictError,
|
ConflictError,
|
||||||
CommandEvaluationError,
|
CommandEvaluationError,
|
||||||
@@ -19,8 +19,6 @@ import {
|
|||||||
} from "@opencode-ai/protocol/errors"
|
} from "@opencode-ai/protocol/errors"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
|
||||||
const DefaultSessionsLimit = 50
|
|
||||||
|
|
||||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
@@ -31,42 +29,18 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
const query =
|
const query =
|
||||||
ctx.query.cursor !== undefined
|
ctx.query.cursor !== undefined
|
||||||
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
|
? yield* SessionsQuery.Cursor.parse(ctx.query.cursor).pipe(
|
||||||
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||||
)
|
)
|
||||||
: ctx.query
|
: ctx.query
|
||||||
const page = yield* session.list({
|
const page = yield* session.list({
|
||||||
...query,
|
...query,
|
||||||
workspaceID: query.workspace,
|
workspaceID: query.workspace,
|
||||||
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
limit: ctx.query.limit ?? SessionsQuery.DefaultLimit,
|
||||||
})
|
})
|
||||||
const sessions = page.data
|
|
||||||
const first = sessions[0]
|
|
||||||
const last = sessions.at(-1)
|
|
||||||
return {
|
return {
|
||||||
data: sessions,
|
data: page.data,
|
||||||
cursor: {
|
cursor: SessionsQuery.Cursor.page(query, page.data),
|
||||||
previous: first
|
|
||||||
? SessionsCursor.make({
|
|
||||||
...query,
|
|
||||||
anchor: {
|
|
||||||
id: first.id,
|
|
||||||
time: DateTime.toEpochMillis(first.time.updated),
|
|
||||||
direction: "previous",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: undefined,
|
|
||||||
next: last
|
|
||||||
? SessionsCursor.make({
|
|
||||||
...query,
|
|
||||||
anchor: {
|
|
||||||
id: last.id,
|
|
||||||
time: DateTime.toEpochMillis(last.time.updated),
|
|
||||||
direction: "next",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -177,7 +177,7 @@ and plugin options.
|
|||||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
|
| `ctx.session` | `create`, `get`, `list`, `messages`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
|
||||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||||
| `ctx.tool` | `transform` and `hook` |
|
| `ctx.tool` | `transform` and `hook` |
|
||||||
| `ctx.aisdk` | `hook` |
|
| `ctx.aisdk` | `hook` |
|
||||||
|
|||||||
Reference in New Issue
Block a user