1
0
Эх сурвалжийг харах

feat(session): expose transient generation API (#37433)

Kit Langton 4 долоо хоног өмнө
parent
commit
3db4458e6c

+ 7 - 0
.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.

+ 27 - 18
packages/client/src/effect/api/api.ts

@@ -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.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_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_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
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<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]

+ 14 - 0
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<SessionLogOutput> =>
         sse<SessionLogOutput>(
           {

+ 9 - 0
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"]

+ 4 - 0
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"],

+ 1 - 0
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),

+ 2 - 0
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({

+ 2 - 1
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<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 - 0
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")),

+ 21 - 0
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 = {

+ 1 - 1
packages/plugin/src/v2/effect/session.ts

@@ -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
packages/plugin/src/v2/promise/session.ts

@@ -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 - 0
packages/protocol/src/groups/session.ts

@@ -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 - 0
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) =>