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

This commit is contained in:
Kit Langton 2026-06-05 16:36:26 -04:00 committed by GitHub
commit 025e1ac69f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 202 additions and 30 deletions

View file

@ -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" }],
},
},
},
},
}),
),
)