diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 5fa7a48b8644..eb69c17a3124 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -485,6 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) { assistantMessageID: message.id, agent: message.agent, model: message.model, + started: 1700000002000, }) } diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index 2c4c1cde1215..a581b7441562 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -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"] diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 43095eafd7d5..7e6bd97a7bc5 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -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", diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 129dbe775137..0d3854c5377a 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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" @@ -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), @@ -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 @@ -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")), + ) +}) diff --git a/packages/core/test/plugin-message.test.ts b/packages/core/test/plugin-message.test.ts new file mode 100644 index 000000000000..4963d29da1b5 --- /dev/null +++ b/packages/core/test/plugin-message.test.ts @@ -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"]) + }), +) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 6cbb5230a73c..3b3be7137000 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -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"), @@ -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"), + }, }, } } diff --git a/packages/plugin/src/effect/message.ts b/packages/plugin/src/effect/message.ts new file mode 100644 index 000000000000..fb9332f75c09 --- /dev/null +++ b/packages/plugin/src/effect/message.ts @@ -0,0 +1,3 @@ +import type { MessageApi } from "@opencode/client/effect/api" + +export interface MessageDomain extends MessageApi {} diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index b19fac7e572d..18df3771da8b 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -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" @@ -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 diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index bea12a931283..c279c48dfa89 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -165,6 +165,7 @@ export type SessionDomain = Pick< | "move" | "wait" | "context" + | "message" > & { readonly hook: ModelHooks } diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index ac0c845f1214..f9781bfd961b 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -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 @@ -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))))), @@ -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) => diff --git a/packages/plugin/src/promise/message.ts b/packages/plugin/src/promise/message.ts new file mode 100644 index 000000000000..71a43829e4eb --- /dev/null +++ b/packages/plugin/src/promise/message.ts @@ -0,0 +1,3 @@ +import type { MessageApi } from "@opencode/client/promise/api" + +export interface MessageDomain extends MessageApi {} diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index ed94b0930201..92cfcb05ea35 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -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" @@ -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 diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index ac3aa3888a5b..5ba70d678d32 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -165,6 +165,7 @@ export type SessionDomain = Pick< | "move" | "wait" | "context" + | "message" > & { readonly hook: ModelHooks }