Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/effect/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
3 changes: 2 additions & 1 deletion packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -585,7 +586,7 @@ export function make(options: ClientOptions) {
permissions: input?.["permissions"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
Expand Down
21 changes: 21 additions & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/client/test/session-parent-create.test.ts
Original file line number Diff line number Diff line change
@@ -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" })
})
9 changes: 7 additions & 2 deletions packages/core/src/plugin/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 31 additions & 2 deletions packages/core/test/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion packages/protocol/src/groups/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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.",
}),
),
)
Expand Down
6 changes: 4 additions & 2 deletions packages/server/src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}
}),
)
Expand Down
65 changes: 65 additions & 0 deletions packages/server/test/session-create.test.ts
Original file line number Diff line number Diff line change
@@ -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),
)
Loading