Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/client/src/promise/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
122 changes: 121 additions & 1 deletion packages/core/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 = <T>(value: T) => value as DeepMutable<T>
type SessionListInput = Exclude<Parameters<Plugin.Context["session"]["list"]>[0], undefined>
type SessionListCursor = Exclude<SessionListInput["cursor"], undefined>

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",
Expand Down Expand Up @@ -67,6 +78,57 @@ export const make = Effect.fn("PluginHost.make")(function* (
ref.directory === location.directory && ref.workspaceID === location.workspaceID
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
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<Plugin.Context["session"]["messages"]>[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,
Expand Down Expand Up @@ -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,
Expand All @@ -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("")
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/plugin/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Session } from "../session.js"
export interface Interface {
readonly session: Pick<
Session.Interface,
| "list"
| "get"
| "create"
| "messages"
Expand Down Expand Up @@ -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)),
Expand Down
54 changes: 53 additions & 1 deletion packages/core/test/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"

Expand All @@ -24,6 +25,57 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, 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
Expand Down
3 changes: 3 additions & 0 deletions packages/core/test/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
35 changes: 35 additions & 0 deletions packages/core/test/plugin/promise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 10 additions & 2 deletions packages/plugin/src/effect/session.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -45,9 +45,17 @@ export interface SessionHooks {
readonly "http.response": SessionHttpResponse
}

type SessionListInput = Exclude<Parameters<SessionApi<unknown>["list"]>[0], undefined>

export type SessionChildrenInput = Pick<SessionListInput, "cursor" | "limit"> & {
readonly sessionID: Session.ID
}

export type SessionDomain = Pick<
SessionApi<unknown>,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
"list" | "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly children: (input: SessionChildrenInput) => ReturnType<SessionApi<unknown>["list"]>
readonly messages: MessageApi<unknown>["list"]
readonly hook: ModelHooks<SessionHooks>
}
13 changes: 13 additions & 0 deletions packages/plugin/src/promise/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -119,6 +120,10 @@ export function fromPromise(plugin: Plugin) {
callback(draft)
}),
)
const sessionList = adaptApiMethod<Context["session"]["list"]>(
SessionEndpoints["session.list"],
host.session.list,
)

const context2: Context = {
app: host.app,
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 10 additions & 2 deletions packages/plugin/src/promise/session.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -45,9 +45,17 @@ export interface SessionHooks {
readonly "http.response": SessionHttpResponse
}

type SessionListInput = Exclude<Parameters<SessionApi["list"]>[0], undefined>

export type SessionChildrenInput = Pick<SessionListInput, "cursor" | "limit"> & {
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<SessionApi["list"]>
readonly messages: MessageApi["list"]
readonly hook: ModelHooks<SessionHooks>
}
Loading
Loading