fix(core): validate public session model switches (#31012)
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
export * as OpenCode from "./opencode"
|
export * as OpenCode from "./opencode"
|
||||||
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { Catalog } from "../catalog"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { LocationServiceMap } from "../location-layer"
|
import { LocationServiceMap } from "../location-layer"
|
||||||
|
import { PluginBoot } from "../plugin/boot"
|
||||||
import { ProjectV2 } from "../project"
|
import { ProjectV2 } from "../project"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import * as SessionExecutionLocal from "../session/execution/local"
|
import * as SessionExecutionLocal from "../session/execution/local"
|
||||||
@@ -21,16 +23,61 @@ export interface Interface {
|
|||||||
/** Intentional public native API for Effect applications embedding OpenCode. */
|
/** Intentional public native API for Effect applications embedding OpenCode. */
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
|
||||||
|
|
||||||
const SessionsLayer = SessionV2.layer.pipe(
|
class SessionModelValidation extends Context.Service<
|
||||||
Layer.provide(SessionProjector.layer),
|
SessionModelValidation,
|
||||||
Layer.provide(SessionExecutionLocal.layer),
|
{
|
||||||
Layer.provide(LocationServiceMap.layer),
|
readonly validate: (
|
||||||
Layer.provide(SessionStore.layer),
|
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
|
||||||
Layer.provide(EventV2.layer),
|
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
|
||||||
Layer.provide(Database.defaultLayer),
|
}
|
||||||
Layer.provide(ProjectV2.defaultLayer),
|
>()("@opencode/public/OpenCode/SessionModelValidation") {}
|
||||||
Layer.orDie,
|
|
||||||
|
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
|
const ApplicationToolsLayer = ApplicationTools.layer
|
||||||
|
|
||||||
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
// 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* () {
|
Effect.gen(function* () {
|
||||||
const sessions = yield* SessionV2.Service
|
const sessions = yield* SessionV2.Service
|
||||||
const tools = yield* ApplicationTools.Service
|
const tools = yield* ApplicationTools.Service
|
||||||
|
const validation = yield* SessionModelValidation
|
||||||
return Service.of({
|
return Service.of({
|
||||||
tools: { attach: tools.attach },
|
tools: { attach: tools.attach },
|
||||||
sessions: {
|
sessions: {
|
||||||
@@ -51,7 +99,11 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
get: sessions.get,
|
get: sessions.get,
|
||||||
list: sessions.list,
|
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,
|
interrupt: sessions.interrupt,
|
||||||
prompt: (input) =>
|
prompt: (input) =>
|
||||||
sessions.prompt({
|
sessions.prompt({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export * as Session from "./session"
|
export * as Session from "./session"
|
||||||
|
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
|
import { ModelV2 } from "../model"
|
||||||
import { SessionV2 } from "../session"
|
import { SessionV2 } from "../session"
|
||||||
import { MessageDecodeError } from "../session/error"
|
import { MessageDecodeError } from "../session/error"
|
||||||
import { SessionEvent } from "../session/event"
|
import { SessionEvent } from "../session/event"
|
||||||
@@ -43,6 +44,23 @@ export type NotFoundError = SessionV2.NotFoundError
|
|||||||
export const PromptConflictError = SessionV2.PromptConflictError
|
export const PromptConflictError = SessionV2.PromptConflictError
|
||||||
export type 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 { MessageDecodeError }
|
||||||
|
|
||||||
export interface CreateInput {
|
export interface CreateInput {
|
||||||
@@ -89,7 +107,9 @@ export interface Interface {
|
|||||||
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||||
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
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. */
|
/** 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 interrupt: (sessionID: ID) => Effect.Effect<void>
|
||||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
|
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
|
||||||
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(OpenCode.layer)
|
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", () =>
|
it.effect("switches to an available model and variant", () =>
|
||||||
Effect.gen(function* () {
|
Effect.acquireRelease(
|
||||||
const opencode = yield* OpenCode.Service
|
Effect.promise(() => tmpdir()),
|
||||||
const targetID = Session.ID.make("ses_public_switch_target")
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
const otherID = Session.ID.make("ses_public_switch_other")
|
).pipe(
|
||||||
const model = Schema.decodeUnknownSync(Model.Ref)({
|
Effect.flatMap((tmp) =>
|
||||||
id: "claude-sonnet-4-5",
|
Effect.gen(function* () {
|
||||||
providerID: "anthropic",
|
yield* writeProvider(tmp.path)
|
||||||
variant: "high",
|
const opencode = yield* OpenCode.Service
|
||||||
})
|
const sessionID = Session.ID.make("ses_public_switch_available")
|
||||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") })
|
const model = ref({ variant: "fast" })
|
||||||
yield* opencode.sessions.create({ id: targetID, location })
|
yield* opencode.sessions.create({
|
||||||
yield* opencode.sessions.create({ id: otherID, location })
|
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(sessionID)).model).toEqual(model)
|
||||||
expect((yield* opencode.sessions.get(otherID)).model).toBeUndefined()
|
}),
|
||||||
}),
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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", () =>
|
it.effect("preserves the typed not-found error for a missing Session", () =>
|
||||||
@@ -71,7 +143,35 @@ describe("public native OpenCode API", () => {
|
|||||||
.pipe(Effect.flip)
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Session.NotFoundError)
|
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" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user