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
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) {
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
started: 1700000002000,
})
}

Expand Down
1 change: 1 addition & 0 deletions packages/client/src/promise/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type EventApi = Client["event"]
export type GenerateApi = Client["generate"]
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 PermissionApi = Client["permission"]
Expand Down
7 changes: 6 additions & 1 deletion packages/codemode/test/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,17 @@ describe("OpenAPI.fromSpec", () => {
const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl })

expect(result.skipped).toHaveLength(5)
expect(result.skipped).toHaveLength(6)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported",
})
expect(result.skipped).toContainEqual({
method: "POST",
path: "/api/experimental/fs/write",
reason: "request body has no JSON content (declared: application/octet-stream)",
})
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
expect(result.skipped).toContainEqual({
method: "GET",
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { LocationServiceMap } from "../location-service-map.js"
import { Model } from "../model.js"
import { Mcp } from "../mcp/index.js"
import { Session } from "../session.js"
import { SessionMessage } from "../session/message.js"
import { PersistentPty } from "../persistent-pty.js"
import { Provider } from "../provider.js"
import { Reference } from "../reference.js"
Expand Down Expand Up @@ -392,6 +393,30 @@ export const make = Effect.fn("PluginHost.make")(function* (
})
}),
},
message: {
list: Effect.fn("PluginHost.messages")(function* (input) {
if (input.cursor !== undefined && input.order !== undefined)
return yield* Effect.fail(new Error("Cursor cannot be combined with order"))
const decoded = input.cursor === undefined ? undefined : yield* decodeMessageCursor(input.cursor)
const order = decoded?.order ?? input.order ?? "desc"
const messages = yield* sessions.messages({
sessionID: input.sessionID,
limit: input.limit ?? DefaultMessagesLimit,
order,
type: input.type,
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
})
const first = messages[0]
const last = messages.at(-1)
return {
data: messages,
cursor: {
previous: first ? encodeMessageCursor(first, order, "previous") : undefined,
next: last ? encodeMessageCursor(last, order, "next") : undefined,
},
}
}),
},
permission: {
hook: (name, callback) => hooks.register("permission", name, callback),
list: (input) => permission.forSession(input.sessionID),
Expand Down Expand Up @@ -547,6 +572,14 @@ export const make = Effect.fn("PluginHost.make")(function* (
.pipe(Effect.map((interrupted) => ({ interrupted }))),
wait: (input) => sessions.wait(input.sessionID),
context: (input) => sessions.context(input.sessionID),
message: {
get: Effect.fn(function* (input) {
yield* sessions.get(input.sessionID)
const message = yield* sessions.message(input)
if (!message) return yield* Effect.fail(new Error(`Message not found: ${input.messageID}`))
return message
}),
},
},
}
return context
Expand Down Expand Up @@ -655,3 +688,29 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
function credential(value: Credential.OAuth) {
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
}

const DefaultMessagesLimit = 50

const MessageCursor = Schema.Struct({
id: SessionMessage.ID,
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
})

function encodeMessageCursor(
message: SessionMessage.Info,
order: "asc" | "desc",
direction: "previous" | "next",
) {
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
}

const decodeMessageCursor = Effect.fn("PluginHost.decodeMessageCursor")(function* (cursor: string) {
const parsed = yield* Effect.try({
try: () => JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")),
catch: () => new Error("Invalid cursor"),
})
return yield* Schema.decodeUnknownEffect(MessageCursor)(parsed).pipe(
Effect.mapError(() => new Error("Invalid cursor")),
)
})
75 changes: 75 additions & 0 deletions packages/core/test/plugin-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { expect } from "bun:test"
import { Bus } from "@opencode/core/bus"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { SessionEvent } from "@opencode/core/session/event"
import { fromPromise } from "@opencode/plugin/promise/adapter"
import { Effect } from "effect"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"

const it = testEffect(PluginTestLayer)

it.effect("exposes session messages through the plugin host", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const bus = yield* Bus.Service
const host = yield* PluginHost.make(plugins)
const session = yield* host.session.create({})

const empty = yield* host.message.list({ sessionID: session.id })
expect(empty.data).toEqual([])

yield* bus.publish(SessionEvent.Synthetic, { sessionID: session.id, text: "hello" })
const listed = yield* host.message.list({ sessionID: session.id })
expect(listed.data.length).toBe(1)
const first = listed.data[0]
if (!first || first.type !== "synthetic") return yield* Effect.die("expected synthetic message")
expect(first.text).toBe("hello")
expect(listed.cursor.previous).toBeDefined()
expect(listed.cursor.next).toBeDefined()

const fetched = yield* host.session.message.get({ sessionID: session.id, messageID: first.id })
expect(fetched.id).toBe(first.id)

const missing = yield* host.session.message
.get({ sessionID: session.id, messageID: first.id.replace(/^msg_/, "msg_missing_") as typeof first.id })
.pipe(Effect.flip)
expect(String(missing)).toContain("Message not found")

const invalid = yield* host.message.list({ sessionID: session.id, cursor: "invalid" }).pipe(Effect.flip)
expect(String(invalid)).toContain("Invalid cursor")

const combined = yield* host.message
.list({ sessionID: session.id, cursor: listed.cursor.next, order: "asc" })
.pipe(Effect.flip)
expect(String(combined)).toContain("Cursor cannot be combined")
}),
)

it.effect("exposes session messages to promise plugins", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const bus = yield* Bus.Service
const host = yield* PluginHost.make(plugins)
const session = yield* host.session.create({})
yield* bus.publish(SessionEvent.Synthetic, { sessionID: session.id, text: "hello promise" })

const seen: string[] = []
const definition = fromPromise({
id: "message-list",
async setup(ctx) {
const listed = await ctx.message.list({ sessionID: session.id })
const first = listed.data[0]
if (first?.type === "synthetic") seen.push(first.text)
if (first) {
const fetched = await ctx.session.message.get({ sessionID: session.id, messageID: first.id })
if (fetched.type === "synthetic") seen.push(fetched.text)
}
},
})
yield* definition.effect(host)

expect(seen).toEqual(["hello promise", "hello promise"])
}),
)
6 changes: 6 additions & 0 deletions packages/core/test/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused mcp.transform"),
reload: () => Effect.die("unused mcp.reload"),
},
message: overrides.message ?? {
list: () => Effect.die("unused message.list"),
},
permission: overrides.permission ?? {
hook: () => Effect.die("unused permission.hook"),
list: () => Effect.die("unused permission.list"),
Expand Down Expand Up @@ -175,6 +178,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
context: overrides.session?.context ?? (() => Effect.die("unused session.context")),
message: overrides.session?.message ?? {
get: () => Effect.die("unused session.message.get"),
},
},
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin/src/effect/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { MessageApi } from "@opencode/client/effect/api"

export interface MessageDomain extends MessageApi<unknown> {}
2 changes: 2 additions & 0 deletions packages/plugin/src/effect/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { MessageDomain } from "./message.js"
import type { ModelDomain } from "./model.js"
import type { PermissionDomain } from "./permission.js"
import type { ProviderDomain } from "./provider.js"
Expand Down Expand Up @@ -36,6 +37,7 @@ export interface Context {
}
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly message: MessageDomain
readonly model: ModelDomain
readonly generate: GenerateApi<unknown>
readonly permission: PermissionDomain
Expand Down
1 change: 1 addition & 0 deletions packages/plugin/src/effect/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export type SessionDomain = Pick<
| "move"
| "wait"
| "context"
| "message"
> & {
readonly hook: ModelHooks<SessionHooks>
}
7 changes: 7 additions & 0 deletions packages/plugin/src/promise/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export function fromPromise(plugin: Plugin) {
const GenerateEndpoints = ClientApi.groups["server.generate"].endpoints
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
const MessageEndpoints = ClientApi.groups["server.message"].endpoints
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
const PermissionEndpoints = ClientApi.groups["server.permission"].endpoints
Expand Down Expand Up @@ -433,6 +434,9 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.mcp),
reload: () => run(host.mcp.reload()),
},
message: {
list: adaptApiMethod(MessageEndpoints["session.messages"], host.message.list),
},
permission: {
hook: (name, callback) =>
register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
Expand Down Expand Up @@ -586,6 +590,9 @@ export function fromPromise(plugin: Plugin) {
move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move),
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context),
message: {
get: adaptApiMethod(SessionEndpoints["session.message"], host.session.message.get),
},
},
shell: {
hook: (name, callback) =>
Expand Down
3 changes: 3 additions & 0 deletions packages/plugin/src/promise/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { MessageApi } from "@opencode/client/promise/api"

export interface MessageDomain extends MessageApi {}
2 changes: 2 additions & 0 deletions packages/plugin/src/promise/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { CommandDomain } from "./command.js"
import type { EventDomain } from "./event.js"
import type { IntegrationDomain } from "./integration.js"
import type { MCPDomain } from "./mcp.js"
import type { MessageDomain } from "./message.js"
import type { ModelDomain } from "./model.js"
import type { PermissionDomain } from "./permission.js"
import type { ProviderDomain } from "./provider.js"
Expand Down Expand Up @@ -36,6 +37,7 @@ export interface Context {
}
readonly integration: IntegrationDomain
readonly mcp: MCPDomain
readonly message: MessageDomain
readonly model: ModelDomain
readonly generate: GenerateApi
readonly permission: PermissionDomain
Expand Down
1 change: 1 addition & 0 deletions packages/plugin/src/promise/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export type SessionDomain = Pick<
| "move"
| "wait"
| "context"
| "message"
> & {
readonly hook: ModelHooks<SessionHooks>
}
Loading