feat(session): expose transient generation API (#37433)
This commit is contained in:
parent
42e2dde739
commit
3db4458e6c
15 changed files with 157 additions and 39 deletions
7
.changeset/clean-sessions-generate.md
Normal file
7
.changeset/clean-sessions-generate.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
@ -3187,6 +3189,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"]
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue