Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 1f3eb0e5a8 feat(session): expose transient generation API 2026-07-16 23:28:11 -04:00
15 changed files with 157 additions and 39 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@opencode-ai/client": patch
"@opencode-ai/plugin": patch
"@opencode-ai/protocol": patch
---
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
+27 -18
View File
@@ -269,32 +269,40 @@ export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.generate"]>[0]
export type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly after?: Endpoint5_25Request["query"]["after"]
readonly follow?: Endpoint5_25Request["query"]["follow"]
readonly prompt: Endpoint5_25Request["payload"]["prompt"]
}
export type Endpoint5_25Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint5_25Input) => Stream.Stream<Endpoint5_25Output, E>
export type Endpoint5_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.generate"]>>["data"]
export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
export type Endpoint5_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.log"]>[0]
export type Endpoint5_26Input = {
readonly sessionID: Endpoint5_26Request["params"]["sessionID"]
readonly after?: Endpoint5_26Request["query"]["after"]
readonly follow?: Endpoint5_26Request["query"]["follow"]
}
export type Endpoint5_26Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_27Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint5_28Input = {
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
readonly messageID: Endpoint5_28Request["params"]["messageID"]
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint5_28Input = { readonly sessionID: Endpoint5_28Request["params"]["sessionID"] }
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
type Endpoint5_29Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint5_29Input = {
readonly sessionID: Endpoint5_29Request["params"]["sessionID"]
readonly messageID: Endpoint5_29Request["params"]["messageID"]
}
export type Endpoint5_28Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -328,6 +336,7 @@ export interface SessionApi<E = never> {
readonly remove: SessionInstructionsEntryRemoveOperation<E>
}
}
readonly generate: SessionGenerateOperation<E>
readonly log: SessionLogOperation<E>
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
+30 -18
View File
@@ -361,13 +361,24 @@ const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24I
Effect.mapError(mapClientError),
)
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_25Request = Parameters<RawClient["server.session"]["session.generate"]>[0]
type Endpoint5_25Input = {
readonly sessionID: Endpoint5_25Request["params"]["sessionID"]
readonly after?: Endpoint5_25Request["query"]["after"]
readonly follow?: Endpoint5_25Request["query"]["follow"]
readonly prompt: Endpoint5_25Request["payload"]["prompt"]
}
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint5_26Input = {
readonly sessionID: Endpoint5_26Request["params"]["sessionID"]
readonly after?: Endpoint5_26Request["query"]["after"]
readonly follow?: Endpoint5_26Request["query"]["follow"]
}
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -378,22 +389,22 @@ const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25I
),
)
type Endpoint5_26Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] }
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_27Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] }
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint5_28Input = { readonly sessionID: Endpoint5_28Request["params"]["sessionID"] }
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint5_28Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint5_28Input = {
readonly sessionID: Endpoint5_28Request["params"]["sessionID"]
readonly messageID: Endpoint5_28Request["params"]["messageID"]
type Endpoint5_29Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint5_29Input = {
readonly sessionID: Endpoint5_29Request["params"]["sessionID"]
readonly messageID: Endpoint5_29Request["params"]["messageID"]
}
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -421,10 +432,11 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
log: Endpoint5_25(raw),
interrupt: Endpoint5_26(raw),
background: Endpoint5_27(raw),
message: Endpoint5_28(raw),
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
interrupt: Endpoint5_27(raw),
background: Endpoint5_28(raw),
message: Endpoint5_29(raw),
})
type Endpoint6_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@@ -58,6 +58,8 @@ import type {
SessionInstructionsEntryPutOutput,
SessionInstructionsEntryRemoveInput,
SessionInstructionsEntryRemoveOutput,
SessionGenerateInput,
SessionGenerateOutput,
SessionLogInput,
SessionLogOutput,
SessionInterruptInput,
@@ -743,6 +745,18 @@ export function make(options: ClientOptions) {
),
},
},
generate: (input: SessionGenerateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionGenerateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/generate`,
body: { prompt: input["prompt"] },
successStatus: 200,
declaredStatuses: [404, 503, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
log: (input: SessionLogInput, requestOptions?: RequestOptions): AsyncIterable<SessionLogOutput> =>
sse<SessionLogOutput>(
{
@@ -134,6 +134,8 @@ export type SessionMessageCompactionCompleted = {
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
@@ -3176,6 +3178,13 @@ export type SessionInstructionsEntryRemoveInput = {
export type SessionInstructionsEntryRemoveOutput = void
export type SessionGenerateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly prompt: { readonly prompt: string }["prompt"]
}
export type SessionGenerateOutput = SessionGenerateResponse["data"]
export type SessionLogInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly after?: { readonly after?: number | undefined; readonly follow?: boolean | undefined }["after"]
+4
View File
@@ -379,6 +379,7 @@ test("session methods use the public HTTP contract", async () => {
})
}
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/generate")) return Response.json({ data: { text: "A transient answer" } })
if (url.includes("/synthetic")) return Response.json(syntheticAdmission)
if (url.endsWith("/compact")) return Response.json(compactionAdmission)
if (url.includes("/context")) return Response.json({ data: [] })
@@ -403,6 +404,7 @@ test("session methods use the public HTTP contract", async () => {
text: "Hello",
resume: false,
})
const generated = await client.session.generate({ sessionID: "ses_test", prompt: "Summarize this session" })
const synthetic = await client.session.synthetic({
sessionID: "ses_test",
text: "Completed",
@@ -421,6 +423,7 @@ test("session methods use the public HTTP contract", async () => {
expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(generated.text).toBe("A transient answer")
expect(synthetic).toMatchObject({ type: "synthetic", data: { text: "Completed" }, delivery: "queue" })
expect(context).toEqual([])
expect(log).toEqual([modelSwitchedEvent, synced])
@@ -432,6 +435,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
["POST", "http://localhost:3000/api/session/ses_test/generate"],
["POST", "http://localhost:3000/api/session/ses_test/synthetic"],
["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"],
+1
View File
@@ -424,6 +424,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
get: (input) => runtime.session.get(input.sessionID),
prompt: runtime.session.prompt,
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
synthetic: runtime.session.synthetic,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
+2
View File
@@ -231,6 +231,8 @@ export function fromPromise(plugin: Plugin) {
resume: input.resume ?? undefined,
}),
),
generate: (input) =>
run(host.session.generate({ sessionID: Session.ID.make(input.sessionID), prompt: input.prompt })),
command: (input) =>
run(
host.session.command({
+2 -1
View File
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
export interface Interface {
readonly session: Pick<
SessionV2.Interface,
"get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic"
"get" | "create" | "messages" | "prompt" | "generate" | "command" | "resume" | "interrupt" | "synthetic"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -50,6 +50,7 @@ export const layerWithCell = (cell: Cell) =>
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
+1
View File
@@ -96,6 +96,7 @@ export function host(overrides: Overrides = {}): PluginContext {
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
+21
View File
@@ -24,6 +24,27 @@ import { host as testHost } from "./host"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("forwards transient session generation", () =>
Effect.gen(function* () {
const host = testHost({
session: {
generate: (input) => Effect.succeed({ text: `${input.sessionID}: ${input.prompt}` }),
},
})
yield* PluginPromise.fromPromise(
Plugin.define({
id: "promise-session-generate",
setup: async (ctx) => {
expect(await ctx.session.generate({ sessionID: "ses_generate", prompt: "Summarize" })).toEqual({
text: "ses_generate: Summarize",
})
},
}),
).effect(host)
}),
)
it.effect("forwards synthetic session input", () =>
Effect.gen(function* () {
const input = {
+1 -1
View File
@@ -21,7 +21,7 @@ export interface SessionHooks {
export type SessionDomain = Pick<
SessionApi<unknown>,
"create" | "get" | "prompt" | "command" | "synthetic" | "interrupt"
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
> & {
readonly hook: Hooks<SessionHooks>
}
+4 -1
View File
@@ -19,6 +19,9 @@ export interface SessionHooks {
readonly context: SessionContext
}
export type SessionDomain = Pick<SessionApi, "create" | "get" | "prompt" | "command" | "synthetic" | "interrupt"> & {
export type SessionDomain = Pick<
SessionApi,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
> & {
readonly hook: Hooks<SessionHooks>
}
+18
View File
@@ -543,6 +543,24 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.generate", "/api/session/:sessionID/generate", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ prompt: Schema.String }),
success: Schema.Struct({
data: Schema.Struct({ text: Schema.String }),
}).annotate({ identifier: "SessionGenerateResponse" }),
error: [SessionNotFoundError, ServiceUnavailableError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.generate",
summary: "Generate text from session context",
description: "Generate transient text from the current session context without mutating session history.",
}),
),
)
.add(
HttpApiEndpoint.get("session.log", "/api/experimental/session/:sessionID/log", {
params: { sessionID: Session.ID },
+16
View File
@@ -627,6 +627,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.generate",
Effect.fn(function* (ctx) {
const text = yield* session.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt }).pipe(
Effect.mapError((error) =>
error._tag === "Session.NotFoundError"
? new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
})
: new ServiceUnavailableError({ message: error.message, service: "session generation" }),
),
)
return { data: { text } }
}),
)
.handle(
"session.log",
Effect.fn((ctx) =>