feat(plugin): support parent session creation

Co-authored-by: dbpolito <347400+dbpolito@users.noreply.github.com>
This commit is contained in:
rekram1-node
2026-09-30 19:17:26 +00:00
committed by opencode-agent[bot]
parent b020f1018b
commit d3a156097d
10 changed files with 154 additions and 8 deletions
+1
View File
@@ -198,6 +198,7 @@ export type SessionStatsOperation<E = never> = (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
@@ -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"],
@@ -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,
@@ -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
@@ -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" })
})
+7 -2
View File
@@ -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,
+31 -2
View File
@@ -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")
+4 -1
View File
@@ -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.",
}),
),
)
+4 -2
View File
@@ -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)),
}
}),
)
@@ -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),
)