mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 15:34:02 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2def7e471 | |||
| 3c87211ef6 | |||
| a3f3c4951d |
@@ -4,6 +4,8 @@ import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
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 { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
@@ -341,17 +343,54 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
id: input?.id,
|
||||
title: input?.title,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
location:
|
||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
}),
|
||||
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,
|
||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||
command: runtime.session.command,
|
||||
rename: runtime.session.rename,
|
||||
synthetic: runtime.session.synthetic,
|
||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||
wait: (input) => runtime.session.wait(input.sessionID),
|
||||
},
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
@@ -11,7 +11,18 @@ import { Session } from "../session"
|
||||
export interface Interface {
|
||||
readonly session: Pick<
|
||||
Session.Interface,
|
||||
"get" | "create" | "messages" | "prompt" | "generate" | "command" | "resume" | "interrupt" | "synthetic"
|
||||
| "get"
|
||||
| "create"
|
||||
| "list"
|
||||
| "messages"
|
||||
| "prompt"
|
||||
| "generate"
|
||||
| "command"
|
||||
| "rename"
|
||||
| "resume"
|
||||
| "interrupt"
|
||||
| "synthetic"
|
||||
| "wait"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
@@ -48,13 +59,16 @@ export const layerWithCell = (cell: Cell) =>
|
||||
session: {
|
||||
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
||||
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)),
|
||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
||||
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
||||
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
|
||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
||||
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
|
||||
},
|
||||
job: {
|
||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
||||
|
||||
@@ -103,11 +103,15 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
|
||||
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
|
||||
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")),
|
||||
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
|
||||
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
|
||||
rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
|
||||
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
|
||||
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
|
||||
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
@@ -29,7 +29,9 @@ export interface SessionHooks {
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
SessionApi<unknown>,
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
|
||||
"create" | "get" | "list" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
> & {
|
||||
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 { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
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 { Workspace } from "@opencode-ai/schema/workspace"
|
||||
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 { 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({
|
||||
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>) =>
|
||||
HttpApiGroup.make("server.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.list", "/api/session", {
|
||||
query: SessionsQuery,
|
||||
query: SessionsQuery.Query,
|
||||
success: Schema.Struct({
|
||||
data: Schema.Array(Session.Info),
|
||||
cursor: Schema.Struct({
|
||||
previous: SessionsCursor.pipe(Schema.optional),
|
||||
next: SessionsCursor.pipe(Schema.optional),
|
||||
previous: SessionsQuery.Cursor.pipe(Schema.optional),
|
||||
next: SessionsQuery.Cursor.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "SessionsResponse" }),
|
||||
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 { Effect, Schema } from "effect"
|
||||
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
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) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -33,15 +14,16 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.query.cursor && ctx.query.order !== undefined)
|
||||
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
|
||||
})
|
||||
const decoded = ctx.query.cursor
|
||||
? yield* SessionMessagesCursor.parse(ctx.query.cursor).pipe(
|
||||
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||
)
|
||||
: undefined
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
const messages = yield* session
|
||||
.messages({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
||||
limit: ctx.query.limit ?? SessionMessagesCursor.DefaultLimit,
|
||||
order,
|
||||
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 {
|
||||
data: messages,
|
||||
cursor: {
|
||||
previous: first ? cursor.encode(first, order, "previous") : undefined,
|
||||
next: last ? cursor.encode(last, order, "next") : undefined,
|
||||
},
|
||||
cursor: SessionMessagesCursor.page(messages, order),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
||||
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||
import {
|
||||
ConflictError,
|
||||
CommandEvaluationError,
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
} from "@opencode-ai/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -31,42 +29,18 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
const query =
|
||||
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" })),
|
||||
)
|
||||
: ctx.query
|
||||
const page = yield* session.list({
|
||||
...query,
|
||||
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 {
|
||||
data: sessions,
|
||||
cursor: {
|
||||
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,
|
||||
},
|
||||
data: page.data,
|
||||
cursor: SessionsQuery.Cursor.page(query, page.data),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ and plugin options.
|
||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `synthetic`, `interrupt`, and `hook` |
|
||||
| `ctx.session` | `create`, `get`, `list`, `messages`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
|
||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||
| `ctx.tool` | `transform` and `hook` |
|
||||
| `ctx.aisdk` | `hook` |
|
||||
|
||||
Reference in New Issue
Block a user