diff --git a/.changeset/clean-sessions-generate.md b/.changeset/clean-sessions-generate.md new file mode 100644 index 0000000000..c7f0d2c909 --- /dev/null +++ b/.changeset/clean-sessions-generate.md @@ -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. diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 27da772894..2aee13e673 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -269,32 +269,40 @@ export type SessionInstructionsEntryRemoveOperation = ( input: Endpoint5_24Input, ) => Effect.Effect -type Endpoint5_25Request = Parameters[0] +type Endpoint5_25Request = Parameters[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>> -export type SessionLogOperation = (input: Endpoint5_25Input) => Stream.Stream +export type Endpoint5_25Output = EffectValue>["data"] +export type SessionGenerateOperation = (input: Endpoint5_25Input) => Effect.Effect -type Endpoint5_26Request = Parameters[0] -export type Endpoint5_26Input = { readonly sessionID: Endpoint5_26Request["params"]["sessionID"] } -export type Endpoint5_26Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint5_26Input) => Effect.Effect +type Endpoint5_26Request = Parameters[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>> +export type SessionLogOperation = (input: Endpoint5_26Input) => Stream.Stream -type Endpoint5_27Request = Parameters[0] +type Endpoint5_27Request = Parameters[0] export type Endpoint5_27Input = { readonly sessionID: Endpoint5_27Request["params"]["sessionID"] } -export type Endpoint5_27Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint5_27Input) => Effect.Effect +export type Endpoint5_27Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint5_27Input) => Effect.Effect -type Endpoint5_28Request = Parameters[0] -export type Endpoint5_28Input = { - readonly sessionID: Endpoint5_28Request["params"]["sessionID"] - readonly messageID: Endpoint5_28Request["params"]["messageID"] +type Endpoint5_28Request = Parameters[0] +export type Endpoint5_28Input = { readonly sessionID: Endpoint5_28Request["params"]["sessionID"] } +export type Endpoint5_28Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint5_28Input) => Effect.Effect + +type Endpoint5_29Request = Parameters[0] +export type Endpoint5_29Input = { + readonly sessionID: Endpoint5_29Request["params"]["sessionID"] + readonly messageID: Endpoint5_29Request["params"]["messageID"] } -export type Endpoint5_28Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint5_28Input) => Effect.Effect +export type Endpoint5_29Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint5_29Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -328,6 +336,7 @@ export interface SessionApi { readonly remove: SessionInstructionsEntryRemoveOperation } } + readonly generate: SessionGenerateOperation readonly log: SessionLogOperation readonly interrupt: SessionInterruptOperation readonly background: SessionBackgroundOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index c7a2f86035..0de7fad7bd 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -361,13 +361,24 @@ const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24I Effect.mapError(mapClientError), ) -type Endpoint5_25Request = Parameters[0] +type Endpoint5_25Request = Parameters[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[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[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[0] +type Endpoint5_27Request = Parameters[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[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[0] -type Endpoint5_28Input = { - readonly sessionID: Endpoint5_28Request["params"]["sessionID"] - readonly messageID: Endpoint5_28Request["params"]["messageID"] +type Endpoint5_29Request = Parameters[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[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 3783a5e5c0..ea5e790ea6 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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 => sse( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a6f20cf2f9..a22956f74b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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"] diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index fd836272b0..d3c966ef9b 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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"], diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 02927d583d..eb977dc811 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index ad9cfc3bf1..d3b3332b61 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -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({ diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index e79b498bd4..e07bb2b031 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -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 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)), diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 07a06e7744..85c43d4304 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -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")), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 34d8e1f975..7922d965bb 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -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 = { diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts index 836f4431c6..73b87fb3d1 100644 --- a/packages/plugin/src/v2/effect/session.ts +++ b/packages/plugin/src/v2/effect/session.ts @@ -21,7 +21,7 @@ export interface SessionHooks { export type SessionDomain = Pick< SessionApi, - "create" | "get" | "prompt" | "command" | "synthetic" | "interrupt" + "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" > & { readonly hook: Hooks } diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts index 387d6fe007..99346de3ea 100644 --- a/packages/plugin/src/v2/promise/session.ts +++ b/packages/plugin/src/v2/promise/session.ts @@ -19,6 +19,9 @@ export interface SessionHooks { readonly context: SessionContext } -export type SessionDomain = Pick & { +export type SessionDomain = Pick< + SessionApi, + "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" +> & { readonly hook: Hooks } diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 753514a050..3e9a4a5015 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -543,6 +543,24 @@ export const makeSessionGroup = (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 }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index fa0b0e67ff..02d1c8d80a 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -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) =>