Просмотр исходного кода

fix(core): validate public session model switches (#31012)

Kit Langton 2 месяцев назад
Родитель
Сommit
025e1ac69f

+ 62 - 10
packages/core/src/public/opencode.ts

@@ -1,9 +1,11 @@
 export * as OpenCode from "./opencode"
 
 import { Context, Effect, Layer } from "effect"
+import { Catalog } from "../catalog"
 import { Database } from "../database/database"
 import { EventV2 } from "../event"
 import { LocationServiceMap } from "../location-layer"
+import { PluginBoot } from "../plugin/boot"
 import { ProjectV2 } from "../project"
 import { SessionV2 } from "../session"
 import * as SessionExecutionLocal from "../session/execution/local"
@@ -21,16 +23,61 @@ export interface Interface {
 /** Intentional public native API for Effect applications embedding OpenCode. */
 export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
 
-const SessionsLayer = SessionV2.layer.pipe(
-  Layer.provide(SessionProjector.layer),
-  Layer.provide(SessionExecutionLocal.layer),
-  Layer.provide(LocationServiceMap.layer),
-  Layer.provide(SessionStore.layer),
-  Layer.provide(EventV2.layer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(ProjectV2.defaultLayer),
-  Layer.orDie,
+class SessionModelValidation extends Context.Service<
+  SessionModelValidation,
+  {
+    readonly validate: (
+      input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
+    ) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
+  }
+>()("@opencode/public/OpenCode/SessionModelValidation") {}
+
+const LocationServicesLayer = LocationServiceMap.layer
+const SessionModelValidationLayer = Layer.effect(
+  SessionModelValidation,
+  Effect.gen(function* () {
+    const locations = yield* LocationServiceMap
+    return SessionModelValidation.of({
+      validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) {
+        yield* Effect.gen(function* () {
+          yield* (yield* PluginBoot.Service).wait()
+          const catalog = yield* Catalog.Service
+          const model = (yield* catalog.model.available()).find(
+            (model) => model.providerID === input.model.providerID && model.id === input.model.id,
+          )
+          if (!model)
+            return yield* new Session.ModelUnavailableError({
+              providerID: input.model.providerID,
+              modelID: input.model.id,
+            })
+          if (
+            input.model.variant !== undefined &&
+            input.model.variant !== "default" &&
+            !model.variants.some((variant) => variant.id === input.model.variant)
+          )
+            return yield* new Session.VariantUnavailableError({
+              providerID: input.model.providerID,
+              modelID: input.model.id,
+              variant: input.model.variant,
+            })
+        }).pipe(Effect.provide(locations.get(input.location)))
+      }),
+    })
+  }),
 )
+
+const SessionsLayer = Layer.merge(
+  SessionV2.layer.pipe(
+    Layer.provide(SessionProjector.layer),
+    Layer.provide(SessionExecutionLocal.layer),
+    Layer.provide(SessionStore.layer),
+    Layer.provide(EventV2.layer),
+    Layer.provide(Database.defaultLayer),
+    Layer.provide(ProjectV2.defaultLayer),
+    Layer.orDie,
+  ),
+  SessionModelValidationLayer,
+).pipe(Layer.provide(LocationServicesLayer))
 const ApplicationToolsLayer = ApplicationTools.layer
 
 // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
@@ -39,6 +86,7 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
     const sessions = yield* SessionV2.Service
     const tools = yield* ApplicationTools.Service
+    const validation = yield* SessionModelValidation
     return Service.of({
       tools: { attach: tools.attach },
       sessions: {
@@ -51,7 +99,11 @@ export const layer = Layer.effect(
           }),
         get: sessions.get,
         list: sessions.list,
-        switchModel: sessions.switchModel,
+        switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
+          const session = yield* sessions.get(input.sessionID)
+          yield* validation.validate({ ...input, location: session.location })
+          yield* sessions.switchModel(input)
+        }),
         interrupt: sessions.interrupt,
         prompt: (input) =>
           sessions.prompt({

+ 22 - 2
packages/core/src/public/session.ts

@@ -1,7 +1,8 @@
 export * as Session from "./session"
 
-import { Effect, Stream } from "effect"
+import { Effect, Schema, Stream } from "effect"
 import { EventV2 } from "../event"
+import { ModelV2 } from "../model"
 import { SessionV2 } from "../session"
 import { MessageDecodeError } from "../session/error"
 import { SessionEvent } from "../session/event"
@@ -43,6 +44,23 @@ export type NotFoundError = SessionV2.NotFoundError
 export const PromptConflictError = SessionV2.PromptConflictError
 export type PromptConflictError = SessionV2.PromptConflictError
 
+export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
+  "Session.ModelUnavailableError",
+  {
+    providerID: Model.Ref.fields.providerID,
+    modelID: Model.Ref.fields.id,
+  },
+) {}
+
+export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
+  "Session.VariantUnavailableError",
+  {
+    providerID: Model.Ref.fields.providerID,
+    modelID: Model.Ref.fields.id,
+    variant: ModelV2.VariantID,
+  },
+) {}
+
 export { MessageDecodeError }
 
 export interface CreateInput {
@@ -89,7 +107,9 @@ export interface Interface {
   readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
   readonly list: (input?: ListInput) => Effect.Effect<Info[]>
   readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
-  readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
+  readonly switchModel: (
+    input: SwitchModelInput,
+  ) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
   /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
   readonly interrupt: (sessionID: ID) => Effect.Effect<void>
   readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>

+ 118 - 18
packages/core/test/public-opencode.test.ts

@@ -1,6 +1,9 @@
+import fs from "fs/promises"
+import path from "path"
 import { describe, expect } from "bun:test"
 import { Effect, Schema } from "effect"
 import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
+import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 
 const it = testEffect(OpenCode.layer)
@@ -38,25 +41,94 @@ describe("public native OpenCode API", () => {
     }),
   )
 
-  it.effect("switches the exact Session to the exact model through the durable facade", () =>
-    Effect.gen(function* () {
-      const opencode = yield* OpenCode.Service
-      const targetID = Session.ID.make("ses_public_switch_target")
-      const otherID = Session.ID.make("ses_public_switch_other")
-      const model = Schema.decodeUnknownSync(Model.Ref)({
-        id: "claude-sonnet-4-5",
-        providerID: "anthropic",
-        variant: "high",
-      })
-      const location = Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") })
-      yield* opencode.sessions.create({ id: targetID, location })
-      yield* opencode.sessions.create({ id: otherID, location })
+  it.effect("switches to an available model and variant", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          yield* writeProvider(tmp.path)
+          const opencode = yield* OpenCode.Service
+          const sessionID = Session.ID.make("ses_public_switch_available")
+          const model = ref({ variant: "fast" })
+          yield* opencode.sessions.create({
+            id: sessionID,
+            location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
+          })
 
-      yield* opencode.sessions.switchModel({ sessionID: targetID, model })
+          yield* opencode.sessions.switchModel({ sessionID, model })
 
-      expect((yield* opencode.sessions.get(targetID)).model).toEqual(model)
-      expect((yield* opencode.sessions.get(otherID)).model).toBeUndefined()
-    }),
+          expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
+        }),
+      ),
+    ),
+  )
+
+  it.effect("rejects missing and Location-disabled models without changing the Session", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
+      (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
+    ).pipe(
+      Effect.flatMap(([available, disabled]) =>
+        Effect.gen(function* () {
+          yield* writeProvider(available.path)
+          yield* writeProvider(disabled.path, true)
+          const opencode = yield* OpenCode.Service
+          const availableID = Session.ID.make("ses_public_switch_exact_available")
+          const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
+          yield* opencode.sessions.create({
+            id: availableID,
+            location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
+          })
+          yield* opencode.sessions.create({
+            id: disabledID,
+            location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
+          })
+
+          yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
+          const disabledError = yield* opencode.sessions
+            .switchModel({ sessionID: disabledID, model: ref() })
+            .pipe(Effect.flip)
+          const missingError = yield* opencode.sessions
+            .switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
+            .pipe(Effect.flip)
+
+          expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
+          expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
+          expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
+          expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
+        }),
+      ),
+    ),
+  )
+
+  it.effect("rejects an unavailable variant without changing the Session", () =>
+    Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ).pipe(
+      Effect.flatMap((tmp) =>
+        Effect.gen(function* () {
+          yield* writeProvider(tmp.path)
+          const opencode = yield* OpenCode.Service
+          const sessionID = Session.ID.make("ses_public_switch_variant")
+          const selected = ref({ variant: "fast" })
+          yield* opencode.sessions.create({
+            id: sessionID,
+            location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
+          })
+          yield* opencode.sessions.switchModel({ sessionID, model: selected })
+
+          const error = yield* opencode.sessions
+            .switchModel({ sessionID, model: ref({ variant: "unknown" }) })
+            .pipe(Effect.flip)
+
+          expect(error).toBeInstanceOf(Session.VariantUnavailableError)
+          expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected)
+        }),
+      ),
+    ),
   )
 
   it.effect("preserves the typed not-found error for a missing Session", () =>
@@ -71,7 +143,35 @@ describe("public native OpenCode API", () => {
         .pipe(Effect.flip)
 
       expect(error).toBeInstanceOf(Session.NotFoundError)
-      expect(error.sessionID).toBe(sessionID)
+      if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID)
     }),
   )
 })
+
+const ref = (input: { id?: string; variant?: string } = {}) =>
+  Schema.decodeUnknownSync(Model.Ref)({
+    id: input.id ?? "chat",
+    providerID: "public-test",
+    variant: input.variant,
+  })
+
+const writeProvider = (directory: string, disabled = false) =>
+  Effect.promise(() =>
+    fs.writeFile(
+      path.join(directory, "opencode.json"),
+      JSON.stringify({
+        providers: {
+          "public-test": {
+            name: "Public test",
+            api: { type: "native", settings: {} },
+            models: {
+              chat: {
+                disabled,
+                variants: [{ id: "fast" }],
+              },
+            },
+          },
+        },
+      }),
+    ),
+  )