From 044dd1b1efd16e81a4b8fc61c8086f62dc8c45dc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 5 Jun 2026 16:03:02 -0400 Subject: [PATCH] fix(core): validate public session model switches --- packages/core/src/public/opencode.ts | 72 +++++++++-- packages/core/src/public/session.ts | 24 +++- packages/core/test/public-opencode.test.ts | 136 ++++++++++++++++++--- 3 files changed, 202 insertions(+), 30 deletions(-) diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 7e58f2989792..f65dd11af95c 100644 --- a/packages/core/src/public/opencode.ts +++ b/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()("@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 + } +>()("@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({ diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts index 6827be0a035b..6c61aff3b6d0 100644 --- a/packages/core/src/public/session.ts +++ b/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()( + "Session.ModelUnavailableError", + { + providerID: Model.Ref.fields.providerID, + modelID: Model.Ref.fields.id, + }, +) {} + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "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 readonly list: (input?: ListInput) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect - readonly switchModel: (input: SwitchModelInput) => Effect.Effect + readonly switchModel: ( + input: SwitchModelInput, + ) => Effect.Effect /** 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 readonly messages: (input: MessagesInput) => Effect.Effect diff --git a/packages/core/test/public-opencode.test.ts b/packages/core/test/public-opencode.test.ts index 54d1bc7bfb7b..90100439fe25 100644 --- a/packages/core/test/public-opencode.test.ts +++ b/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" }], + }, + }, + }, + }, + }), + ), + )