From 42608433f114207ccbdee37f5d5ad639f4a79091 Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Thu, 20 Aug 2026 03:17:28 +0000 Subject: [PATCH] feat(plugin): expose session history reads --- packages/client/src/promise/api.ts | 1 + packages/core/src/plugin/host.ts | 122 +++++++++++++++++++- packages/core/src/plugin/runtime.ts | 2 + packages/core/test/plugin.test.ts | 54 ++++++++- packages/core/test/plugin/host.ts | 3 + packages/core/test/plugin/promise.test.ts | 35 ++++++ packages/plugin/src/effect/session.ts | 12 +- packages/plugin/src/promise/adapter.ts | 13 +++ packages/plugin/src/promise/session.ts | 12 +- packages/www/content/docs/build/plugins.mdx | 32 ++--- 10 files changed, 264 insertions(+), 22 deletions(-) diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index 65c3d5306418..6e84ab785442 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -6,6 +6,7 @@ export type ConfigApi = Client["config"] export type EventApi = Client["event"] export type IntegrationApi = Client["integration"] export type McpApi = Client["mcp"] +export type MessageApi = Client["message"] export type ModelApi = Client["model"] export type PluginApi = Client["plugin"] export type ProviderApi = Client["provider"] diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 49f177b8e74f..b91b04122851 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -6,7 +6,7 @@ import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Mcp } from "@opencode-ai/schema/mcp" import { App } from "../app.js" -import { Effect, Schema, Stream } from "effect" +import { DateTime, Effect, Schema, Stream } from "effect" import { Agent } from "../agent.js" import { AISDK } from "../aisdk.js" import { Catalog } from "../catalog.js" @@ -27,8 +27,19 @@ import { Tool } from "../tool.js" import { Workspace } from "../workspace.js" import { WebSearch } from "../websearch.js" import { PluginHooks } from "./hooks.js" +import { Session } from "../session.js" +import { SessionMessage } from "../session/message.js" const mutable = (value: T) => value as DeepMutable +type SessionListInput = Exclude[0], undefined> +type SessionListCursor = Exclude + +const MessageCursor = Schema.Struct({ + id: SessionMessage.ID, + order: Schema.Literals(["asc", "desc"]), + direction: Schema.Literals(["previous", "next"]), +}) + export const make = Effect.fn("PluginHost.make")(function* ( plugin: import("../plugin.js").Interface, pluginID: string = "test", @@ -67,6 +78,57 @@ export const make = Effect.fn("PluginHost.make")(function* ( ref.directory === location.directory && ref.workspaceID === location.workspaceID const response = (effect: Effect.Effect) => effect.pipe(Effect.map((data) => ({ location: locationInfo(), data }))) + const sessionList = (input?: SessionListInput, parentID?: Session.ID) => + Effect.gen(function* () { + const decoded = input?.cursor === undefined ? sessionListQuery(input) : yield* decodeSessionCursor(input.cursor) + const query = parentID === undefined ? decoded : { ...decoded, parentID } + const page = yield* runtime.session.list({ ...query, limit: input?.limit ?? 50 }) + const first = page.data[0] + const last = page.data.at(-1) + return { + data: page.data, + cursor: { + previous: + first === undefined + ? undefined + : encodeSessionCursor(query, { + id: first.id, + time: DateTime.toEpochMillis(first.time.updated), + direction: "previous", + }), + next: + last === undefined + ? undefined + : encodeSessionCursor(query, { + id: last.id, + time: DateTime.toEpochMillis(last.time.updated), + direction: "next", + }), + }, + } + }) + const sessionMessages = (input: Parameters[0]) => + Effect.gen(function* () { + if (input.cursor !== undefined && input.order !== undefined) + return yield* Effect.fail(new Error("Invalid cursor")) + const decoded = input.cursor === undefined ? undefined : yield* decodeMessageCursor(input.cursor) + const order = decoded?.order ?? input.order ?? "desc" + const messages = yield* runtime.session.messages({ + sessionID: input.sessionID, + limit: input.limit ?? 50, + order, + cursor: decoded === undefined ? undefined : { id: decoded.id, direction: decoded.direction }, + }) + const first = messages[0] + const last = messages.at(-1) + return { + data: messages, + cursor: { + previous: first === undefined ? undefined : encodeMessageCursor(first, order, "previous"), + next: last === undefined ? undefined : encodeMessageCursor(last, order, "next"), + }, + } + }) return { app, @@ -391,6 +453,9 @@ export const make = Effect.fn("PluginHost.make")(function* ( }, session: { hook: (name, callback, options) => hooks.register("session", name, callback, options), + list: sessionList, + children: (input) => sessionList({ cursor: input.cursor, limit: input.limit }, input.sessionID), + messages: sessionMessages, create: (input) => runtime.session.create({ id: input?.id, @@ -412,6 +477,61 @@ export const make = Effect.fn("PluginHost.make")(function* ( } satisfies Plugin.Context }) +function sessionListQuery(input?: SessionListInput): Session.ListInput { + const common = { + workspaceID: input?.workspace, + search: input?.search, + order: input?.order, + parentID: input?.parentID, + } + if (input?.directory !== undefined) return { ...common, directory: input.directory } + if (input?.project !== undefined) return { ...common, project: input.project, subpath: input.subpath } + return common +} + +function encodeSessionCursor(query: Session.ListInput, anchor: Session.ListAnchor): SessionListCursor { + const value = { + workspace: query.workspaceID, + search: query.search, + order: query.order, + parentID: query.parentID, + anchor, + ...("directory" in query ? { directory: query.directory } : {}), + ...("project" in query ? { project: query.project, subpath: query.subpath } : {}), + } + return Buffer.from(JSON.stringify(value)).toString("base64url") as SessionListCursor +} + +function decodeSessionCursor(input: string) { + return Effect.try({ + try: () => JSON.parse(Buffer.from(input, "base64url").toString("utf8")), + catch: () => new Error("Invalid cursor"), + }).pipe( + Effect.flatMap((value) => { + if (typeof value !== "object" || value === null) return Effect.fail(new Error("Invalid cursor")) + return Schema.decodeUnknownEffect(Session.ListInput)({ + ...value, + workspaceID: "workspace" in value ? value.workspace : undefined, + }) + }), + Effect.mapError(() => new Error("Invalid cursor")), + ) +} + +function encodeMessageCursor(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") { + return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url") +} + +function decodeMessageCursor(input: string) { + return Effect.try({ + try: () => JSON.parse(Buffer.from(input, "base64url").toString("utf8")), + catch: () => new Error("Invalid cursor"), + }).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(MessageCursor)), + Effect.mapError(() => new Error("Invalid cursor")), + ) +} + export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] { const namespace = `plugin:${pluginID .split("") diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index dab43abe6af4..d9225d429939 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -13,6 +13,7 @@ import { Session } from "../session.js" export interface Interface { readonly session: Pick< Session.Interface, + | "list" | "get" | "create" | "messages" @@ -69,6 +70,7 @@ export const layerWithCell = (cell: Cell) => Service, Service.of({ session: { + list: (input) => require(cell, (runtime) => runtime.session.list(input)), get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)), create: (input) => require(cell, (runtime) => runtime.session.create(input)), messages: (input) => require(cell, (runtime) => runtime.session.messages(input)), diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 1419249c390d..759c8fa08afb 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { ToolFailure } from "@opencode-ai/ai" -import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" +import { Context, DateTime, Effect, Exit, Fiber, Schema, Stream } from "effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Agent } from "@opencode-ai/core/agent" @@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { Tool } from "@opencode-ai/core/tool" +import { Money } from "@opencode-ai/schema/money" import { testEffect } from "./lib/effect" import { PluginTestLayer } from "./plugin/fixture" @@ -24,6 +25,57 @@ class Secret extends Context.Service()("@opencode/test/PluginSec const versioned = (plugin: EffectPlugin.Plugin, version = "1") => ({ ...plugin, version }) describe("Plugin", () => { + it.effect("exposes paginated session history reads", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const runtime = yield* PluginRuntime.Service + const location = yield* Location.Service + const parentID = Session.ID.make("ses_parent") + const child = (id: string, updated: number) => + Session.Info.make({ + id: Session.ID.make(id), + parentID, + projectID: location.project.id, + cost: Money.USD.make(0), + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(updated), updated: DateTime.makeUnsafe(updated) }, + location: Location.Ref.make({ directory: location.directory }), + }) + const firstChild = child("ses_first", 1) + const secondChild = child("ses_second", 2) + const seen: unknown[] = [] + const host = yield* PluginHost.make(plugins).pipe( + Effect.provideService( + PluginRuntime.Service, + PluginRuntime.Service.of({ + ...runtime, + session: { + ...runtime.session, + list: (input) => { + seen.push(input) + return Effect.succeed({ data: [input?.anchor === undefined ? firstChild : secondChild] }) + }, + messages: (input) => { + seen.push(input) + return Effect.succeed([]) + }, + }, + }), + ), + ) + + const first = yield* host.session.children({ sessionID: parentID, limit: 1 }) + const second = yield* host.session.children({ sessionID: parentID, limit: 1, cursor: first.cursor.next }) + const messages = yield* host.session.messages({ sessionID: parentID }) + + expect(first.data).toHaveLength(1) + expect(second.data).toHaveLength(1) + expect(second.data[0]?.id).not.toBe(first.data[0]?.id) + expect(messages.data).toEqual([]) + expect(seen).toHaveLength(3) + }), + ) + it.live("exposes public events through the plugin context", () => Effect.gen(function* () { const plugins = yield* Plugin.Service diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index b6c850cc7dbc..ad86ad3cc656 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -115,6 +115,9 @@ export function host(overrides: Overrides = {}): Plugin.Context { }, session: { hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")), + list: overrides.session?.list ?? (() => Effect.die("unused session.list")), + children: overrides.session?.children ?? (() => Effect.die("unused session.children")), + messages: overrides.session?.messages ?? (() => Effect.die("unused session.messages")), create: overrides.session?.create ?? (() => Effect.die("unused session.create")), get: overrides.session?.get ?? (() => Effect.die("unused session.get")), prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 6b4fa7075425..ceefefc98d2f 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -100,6 +100,41 @@ describe("fromPromise", () => { }), ) + it.effect("adapts session history reads through the protocol schema", () => + Effect.gen(function* () { + const seen: unknown[] = [] + const host = testHost({ + session: { + list: (input) => { + seen.push(input) + return Effect.succeed({ data: [], cursor: {} }) + }, + messages: (input) => { + seen.push(input) + return Effect.succeed({ data: [], cursor: {} }) + }, + }, + }) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-session-history", + setup: async (ctx) => { + await ctx.session.list({ parentID: null, limit: 2 }) + await ctx.session.children({ sessionID: Session.ID.make("ses_parent"), limit: 3 }) + await ctx.session.messages({ sessionID: Session.ID.make("ses_parent"), limit: 4, order: "asc" }) + }, + }), + ).effect(host) + + expect(seen).toEqual([ + { parentID: null, limit: 2 }, + { parentID: "ses_parent", limit: 3 }, + { sessionID: "ses_parent", limit: 4, order: "asc" }, + ]) + }), + ) + it.effect("forwards transient session generation", () => Effect.gen(function* () { const host = testHost({ diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index 41ea2918903f..33e708bede8b 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -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 { Agent } from "@opencode-ai/schema/agent" import type { Model } from "@opencode-ai/schema/model" @@ -45,9 +45,17 @@ export interface SessionHooks { readonly "http.response": SessionHttpResponse } +type SessionListInput = Exclude["list"]>[0], undefined> + +export type SessionChildrenInput = Pick & { + readonly sessionID: Session.ID +} + export type SessionDomain = Pick< SessionApi, - "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" + "list" | "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" > & { + readonly children: (input: SessionChildrenInput) => ReturnType["list"]> + readonly messages: MessageApi["list"] readonly hook: ModelHooks } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 5e20d72f5d30..880198f142a1 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -76,6 +76,7 @@ export function fromPromise(plugin: Plugin) { const AgentEndpoints = ClientApi.groups["server.agent"].endpoints const CommandEndpoints = ClientApi.groups["server.command"].endpoints const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints + const MessageEndpoints = ClientApi.groups["server.message"].endpoints const McpEndpoints = ClientApi.groups["server.mcp"].endpoints const ModelEndpoints = ClientApi.groups["server.model"].endpoints const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints @@ -119,6 +120,10 @@ export function fromPromise(plugin: Plugin) { callback(draft) }), ) + const sessionList = adaptApiMethod( + SessionEndpoints["session.list"], + host.session.list, + ) const context2: Context = { app: host.app, @@ -307,6 +312,14 @@ export function fromPromise(plugin: Plugin) { register( host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options), ), + list: sessionList, + children: (input) => + sessionList({ + parentID: input.sessionID, + cursor: input.cursor, + limit: input.limit, + }), + messages: adaptApiMethod(MessageEndpoints["session.messages"], host.session.messages), create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create), get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get), prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt), diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index d4ea141154a6..af1c05ff8af8 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -1,4 +1,4 @@ -import type { SessionApi } from "@opencode-ai/client/promise/api" +import type { MessageApi, SessionApi } from "@opencode-ai/client/promise/api" import type { Message, SystemPart } from "@opencode-ai/ai" import type { Agent } from "@opencode-ai/schema/agent" import type { Model } from "@opencode-ai/schema/model" @@ -45,9 +45,17 @@ export interface SessionHooks { readonly "http.response": SessionHttpResponse } +type SessionListInput = Exclude[0], undefined> + +export type SessionChildrenInput = Pick & { + readonly sessionID: Session.ID +} + export type SessionDomain = Pick< SessionApi, - "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" + "list" | "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait" > & { + readonly children: (input: SessionChildrenInput) => ReturnType + readonly messages: MessageApi["list"] readonly hook: ModelHooks } diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index ebcaee1f2a77..58bc9a37b4f2 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -192,22 +192,22 @@ Its read and action methods use the same inputs and responses as the client. It adds plugin-only methods for transforms, runtime hooks, reloads, registrations, and plugin options. -| Capability | Available operations | -| ---------------------- | -------------------------------------------------------------------------------------------- | -| `ctx.agent` | `list`, `get`, `transform`, `reload` | -| `ctx.catalog.provider` | `list`, `get` | -| `ctx.catalog.model` | `list`, `get`, `default` | -| `ctx.catalog` | `transform`, `reload` | -| `ctx.command` | `list`, `transform`, `reload` | -| `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`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` | -| `ctx.skill` | `list`, `transform`, `reload` | -| `ctx.tool` | `transform` and `hook` | -| `ctx.aisdk` | `hook` | -| `ctx.event` | `subscribe` to the current public server event stream | -| `ctx.options` | Readonly options from the matching config object | +| Capability | Available operations | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `ctx.agent` | `list`, `get`, `transform`, `reload` | +| `ctx.catalog.provider` | `list`, `get` | +| `ctx.catalog.model` | `list`, `get`, `default` | +| `ctx.catalog` | `transform`, `reload` | +| `ctx.command` | `list`, `transform`, `reload` | +| `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` | `list`, `children`, `messages`, `create`, `get`, `prompt`, `generate`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` | +| `ctx.skill` | `list`, `transform`, `reload` | +| `ctx.tool` | `transform` and `hook` | +| `ctx.aisdk` | `hook` | +| `ctx.event` | `subscribe` to the current public server event stream | +| `ctx.options` | Readonly options from the matching config object | ### Transform hooks