From d3a156097dfd43c705297f0394a9e73f86353b86 Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Wed, 30 Sep 2026 19:17:26 +0000 Subject: [PATCH] feat(plugin): support parent session creation Co-authored-by: dbpolito <347400+dbpolito@users.noreply.github.com> --- packages/client/src/effect/api/api.ts | 1 + .../client/src/effect/generated/client.ts | 1 + .../client/src/promise/generated/client.ts | 3 +- .../client/src/promise/generated/types.ts | 21 ++++++ .../client/test/session-parent-create.test.ts | 18 +++++ packages/core/src/plugin/host.ts | 9 ++- packages/core/test/plugin.test.ts | 33 +++++++++- packages/protocol/src/groups/session.ts | 5 +- packages/server/src/handlers/session.ts | 6 +- packages/server/test/session-create.test.ts | 65 +++++++++++++++++++ 10 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 packages/client/test/session-parent-create.test.ts create mode 100644 packages/server/test/session-create.test.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 4e66ee542754..03b56ec7d8df 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -198,6 +198,7 @@ export type SessionStatsOperation = (input?: SessionStatsInput) => Ef export type SessionCreateInput = { readonly id?: Session.ID | undefined + readonly parentID?: Session.ID | undefined readonly title?: string | undefined readonly agent?: Agent.ID | undefined readonly model?: Model.Ref | undefined diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index a8c4a68a6ccd..e54254893672 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -393,6 +393,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses raw["session.create"]({ payload: { id: input?.["id"], + parentID: input?.["parentID"], title: input?.["title"], agent: input?.["agent"], model: input?.["model"], diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 27826bac171a..7e4243c6d6ff 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -577,6 +577,7 @@ export function make(options: ClientOptions) { path: `/api/session`, body: { id: input?.["id"], + parentID: input?.["parentID"], title: input?.["title"], agent: input?.["agent"], model: input?.["model"], @@ -585,7 +586,7 @@ export function make(options: ClientOptions) { permissions: input?.["permissions"], }, successStatus: 200, - declaredStatuses: [400, 401], + declaredStatuses: [400, 401, 404], empty: false, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 5cf39951bb4f..1a450e13981c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2914,6 +2914,7 @@ export type SessionStatsOutput = { data: SessionStatsInfo }["data"] export type SessionCreateInput = { readonly id?: { readonly id?: string | null + readonly parentID?: string | null readonly title?: string | null readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null @@ -2925,8 +2926,23 @@ export type SessionCreateInput = { readonly effect: "allow" | "deny" | "ask" }> | null }["id"] + readonly parentID?: { + readonly id?: string | null + readonly parentID?: string | null + readonly title?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly location?: { readonly directory: string } | null + readonly metadata?: { readonly [x: string]: JsonValue } | null + readonly permissions?: ReadonlyArray<{ + readonly action: string + readonly resource: string + readonly effect: "allow" | "deny" | "ask" + }> | null + }["parentID"] readonly title?: { readonly id?: string | null + readonly parentID?: string | null readonly title?: string | null readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null @@ -2940,6 +2956,7 @@ export type SessionCreateInput = { }["title"] readonly agent?: { readonly id?: string | null + readonly parentID?: string | null readonly title?: string | null readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null @@ -2953,6 +2970,7 @@ export type SessionCreateInput = { }["agent"] readonly model?: { readonly id?: string | null + readonly parentID?: string | null readonly title?: string | null readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null @@ -2966,6 +2984,7 @@ export type SessionCreateInput = { }["model"] readonly location?: { readonly id?: string | null + readonly parentID?: string | null readonly title?: string | null readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null @@ -2979,6 +2998,7 @@ export type SessionCreateInput = { }["location"] readonly metadata?: { readonly id?: string | null + readonly parentID?: string | null readonly title?: string | null readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null @@ -2992,6 +3012,7 @@ export type SessionCreateInput = { }["metadata"] readonly permissions?: { readonly id?: string | null + readonly parentID?: string | null readonly title?: string | null readonly agent?: string | null readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null diff --git a/packages/client/test/session-parent-create.test.ts b/packages/client/test/session-parent-create.test.ts new file mode 100644 index 000000000000..a5ff722f1e9e --- /dev/null +++ b/packages/client/test/session-parent-create.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { OpenCode } from "../src/promise/index" + +test("session.create forwards parentID from the Promise client", async () => { + let body: unknown + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (_input, init) => { + body = JSON.parse(String(init?.body)) + return Response.json({ data: { id: "ses_child" } }) + }, + }) + + const child = await client.session.create({ parentID: "ses_parent", title: "Child" }) + + expect(child.id).toBe("ses_child") + expect(body).toEqual({ parentID: "ses_parent", title: "Child" }) +}) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 37fe4d80b1bc..c2c42c26c132 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -534,8 +534,13 @@ export const make = Effect.fn("PluginHost.make")(function* ( model: input?.model, metadata: input?.metadata, permissions: input?.permissions, - location: - input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), + ...(input?.parentID === undefined + ? { + location: + input?.location ?? + Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }), + } + : { parentID: input.parentID }), }), get: (input) => sessions.get(input.sessionID), switchAgent: sessions.switchAgent, diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index cb2150dfad0d..70746c04708a 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -8,9 +8,10 @@ import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" import { Plugin } from "@opencode/core/plugin" import { PluginModule } from "@opencode/core/plugin/module" +import { Session } from "@opencode/core/session" +import { SessionSchema } from "@opencode/core/session/schema" import { Watcher } from "@opencode/core/filesystem/watcher" import { fromPromise } from "@opencode/plugin/promise/adapter" -import { Session } from "@opencode/schema/session" import { testEffect } from "./lib/effect" import { PluginTestLayer } from "./plugin/fixture" @@ -471,6 +472,34 @@ it.effect("normalizes Promise plugin API inputs through JSON", () => }), ) +it.effect("creates child sessions from a plugin at the parent's location", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const sessions = yield* Session.Service + const created = yield* Deferred.make<{ parentID: SessionSchema.ID; childID: SessionSchema.ID }>() + yield* plugins.activate([ + { + id: "child-session", + revision: "1", + effect: (ctx) => + Effect.gen(function* () { + const parent = yield* ctx.session + .create({ title: "Parent", metadata: { source: "parent" } }) + .pipe(Effect.orDie) + const child = yield* ctx.session.create({ parentID: parent.id, title: "Child" }).pipe(Effect.orDie) + yield* Deferred.succeed(created, { parentID: parent.id, childID: child.id }) + }), + }, + ]) + yield* plugins.awaitActivation + const ids = yield* Deferred.await(created) + const parent = yield* sessions.get(ids.parentID) + const child = yield* sessions.get(ids.childID) + + expect(child).toMatchObject({ parentID: parent.id, location: parent.location, metadata: parent.metadata }) + }), +) + it.effect("reloading a plugin replaces its command implementation", () => Effect.gen(function* () { const plugins = yield* Plugin.Service @@ -497,7 +526,7 @@ it.effect("reloading a plugin replaces its command implementation", () => ]) const request = { name: "greet", - invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const }, + invocation: { sessionID: SessionSchema.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const }, } yield* load("1", "before") diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d6cc9358bcf7..32e4a9fdee05 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -220,6 +220,7 @@ export const makeSessionGroup = < HttpApiEndpoint.post("session.create", "/api/session", { payload: Schema.Struct({ id: Session.ID.pipe(Schema.optional), + parentID: Session.ID.pipe(Schema.optional), title: Schema.String.pipe(Schema.optional), agent: Agent.ID.pipe(Schema.optional), model: Model.Ref.pipe(Schema.optional), @@ -228,11 +229,13 @@ export const makeSessionGroup = < permissions: Permission.Ruleset.pipe(Schema.optional), }), success: Schema.Struct({ data: PublicSessionInfo }), + error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ identifier: "session.create", summary: "Create session", - description: "Create a session at the requested location.", + description: + "Create a session at the requested location. A parentID creates a linked child session at its parent's location.", }), ), ) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index cefdef08efd9..7a2586fb323a 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -133,9 +133,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl model: ctx.payload.model, metadata: ctx.payload.metadata, permissions: ctx.payload.permissions, - location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) }, + ...(ctx.payload.parentID === undefined + ? { location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) } } + : { parentID: ctx.payload.parentID }), }) - .pipe(Effect.orDie), + .pipe(Effect.catchTag("Session.NotFoundError", missingSession)), } }), ) diff --git a/packages/server/test/session-create.test.ts b/packages/server/test/session-create.test.ts new file mode 100644 index 000000000000..5d7cbfbacba9 --- /dev/null +++ b/packages/server/test/session-create.test.ts @@ -0,0 +1,65 @@ +import { expect } from "bun:test" +import { Session } from "@opencode/schema/session" +import { Effect, Schema } from "effect" +import { it } from "../../core/test/lib/effect" +import { ServerFetch } from "../src/fetch" + +const SessionResponse = Schema.Struct({ data: Schema.toEncoded(Session.Info) }) +const SessionsResponse = Schema.Struct({ data: Schema.Array(Schema.toEncoded(Session.Info)) }) + +it.live("creates a child at its parent's location and lists it under the parent", () => + Effect.gen(function* () { + const handler = yield* ServerFetch.make({ + app: { version: "test" }, + database: { path: ":memory:" }, + fs: { filewatcher: false }, + }) + const create = (body: unknown) => + Effect.promise(async () => { + const response = await handler( + new Request("http://opencode.local/api/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ) + expect(response.status).toBe(200) + return Schema.decodeUnknownSync(SessionResponse)(await response.json()).data + }) + const parent = yield* create({ title: "Parent" }) + const child = yield* create({ parentID: parent.id, title: "Child", location: { directory: "/unused" } }) + const response = yield* Effect.promise(() => + handler(new Request(`http://opencode.local/api/session?parentID=${parent.id}`)), + ) + expect(response.status).toBe(200) + const children = Schema.decodeUnknownSync(SessionsResponse)(yield* Effect.promise(() => response.json())) + + expect(child).toMatchObject({ parentID: parent.id, location: parent.location }) + expect(children.data.map((session) => session.id)).toEqual([child.id]) + }).pipe(Effect.scoped), +) + +it.live("returns not found when creating a child of a missing session", () => + Effect.gen(function* () { + const handler = yield* ServerFetch.make({ + app: { version: "test" }, + database: { path: ":memory:" }, + fs: { filewatcher: false }, + }) + const parentID = Session.ID.create() + const response = yield* Effect.promise(() => + handler( + new Request("http://opencode.local/api/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parentID, title: "Child" }), + }), + ), + ) + expect(response.status).toBe(404) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + _tag: "SessionNotFoundError", + sessionID: parentID, + }) + }).pipe(Effect.scoped), +)