mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42608433f1 |
@@ -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"]
|
||||
|
||||
@@ -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 = <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",
|
||||
@@ -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,
|
||||
@@ -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("")
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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<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
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<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>
|
||||
}
|
||||
|
||||
@@ -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<Context["session"]["list"]>(
|
||||
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),
|
||||
|
||||
@@ -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<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>
|
||||
}
|
||||
|
||||
+16
-16
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user