fix(core): defer session model validation (#33377)

This commit is contained in:
Kit Langton
2026-06-22 11:07:27 -04:00
committed by GitHub
parent d5980b47e9
commit f9f2280452
6 changed files with 171 additions and 224 deletions
+55 -1
View File
@@ -1,16 +1,20 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect"
import { Tool } from "@opencode-ai/core/public"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolDefinitions } from "./lib/tool"
@@ -136,6 +140,56 @@ describe("LocationServiceMap", () => {
),
)
it.live("rejects an unavailable selected model during location model resolution", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
yield* Effect.promise(() =>
fs.writeFile(
path.join(dir.path, "opencode.json"),
JSON.stringify({
providers: {
unavailable: {
name: "Unavailable",
api: { type: "native", settings: {} },
models: { chat: { disabled: true } },
},
},
}),
),
)
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
SessionV2.Info.make({
id: SessionV2.ID.make("ses_unavailable_model"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: ModelV2.ID.make("chat"),
providerID: ProviderV2.ID.make("unavailable"),
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
),
).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
providerID: "unavailable",
modelID: "chat",
})
}),
),
),
)
it.live("installs public plugins into a location", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+16 -117
View File
@@ -1,9 +1,6 @@
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)
@@ -41,94 +38,24 @@ describe("public native OpenCode API", () => {
}),
)
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) }),
})
it.effect("records model selection without resolving the Location catalog", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_deferred")
const model = Schema.decodeUnknownSync(Model.Ref)({
id: "missing",
providerID: "missing",
variant: "unknown",
})
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }),
})
yield* opencode.sessions.switchModel({ sessionID, model })
yield* opencode.sessions.switchModel({ sessionID, model })
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)
}),
),
),
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
}),
)
it.effect("preserves the typed not-found error for a missing Session", () =>
@@ -147,31 +74,3 @@ describe("public native OpenCode API", () => {
}),
)
})
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" }],
},
},
},
},
}),
),
)
@@ -43,14 +43,6 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
limit: { context: 100, output: 20 },
})
const provider = (api: ProviderV2.Info["api"]) =>
new ProviderV2.Info({
id: ProviderV2.ID.make("test-provider"),
name: "Test provider",
api,
request: { headers: {}, body: {} },
})
describe("SessionRunnerModel", () => {
it.effect("maps catalog OpenAI AI SDK models into native Responses routes", () =>
Effect.gen(function* () {
@@ -194,6 +186,35 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" })
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant_unavailable"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("unknown"),
},
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.VariantUnavailableError",
providerID: "test-provider",
modelID: "test-model",
variant: "unknown",
})
}),
)
it.effect("lowers selected Anthropic Session variants into Messages options", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [