feat(core): honor default session models (#30982)
This commit is contained in:
parent
f26a9e8856
commit
d2204e0ff5
@ -118,6 +118,12 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
||||
|
||||
export type Entry = Document | Directory
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
return entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.findLast((entry) => entry.info[key] !== undefined)?.info[key]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Returns location config documents and supplemental directories from lowest to highest priority. */
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
|
||||
@ -58,8 +58,7 @@ export const Plugin = PluginV2.define({
|
||||
|
||||
yield* agent.update((editor) => {
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = documents.findLast((document) => document.info.default_agent !== undefined)?.info
|
||||
.default_agent
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of editor.list()) {
|
||||
editor.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
|
||||
@ -13,9 +13,15 @@ export const Plugin = PluginV2.define({
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
|
||||
yield* transform((catalog) => {
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
}
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const providerID = ProviderV2.ID.make(id)
|
||||
|
||||
@ -132,7 +132,7 @@ export interface Interface {
|
||||
readonly switchModel: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
model: ModelV2.Ref
|
||||
}) => Effect.Effect<void, OperationUnavailableError>
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@ -385,8 +385,20 @@ export const layer = Layer.effect(
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "switchAgent" })
|
||||
}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "switchModel" })
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
model: input.model,
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
|
||||
@ -128,10 +128,9 @@ export const locationLayer = Layer.effect(
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||
yield* boot.wait()
|
||||
const preferred = yield* catalog.model.default()
|
||||
const selected = session.model
|
||||
? yield* catalog.model.get(session.model.providerID, session.model.id)
|
||||
: (Option.getOrUndefined(preferred.pipe(Option.filter(supported))) ??
|
||||
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
||||
(yield* catalog.model.available()).find(supported))
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
|
||||
|
||||
@ -52,6 +52,20 @@ const provider = {
|
||||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5" }) }),
|
||||
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new Config.Document({ type: "document", info: new Config.Info({}) }),
|
||||
new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5.5" }) }),
|
||||
]
|
||||
|
||||
expect(Config.latest(entries, "model")).toBe("openrouter/openai/gpt-5.5")
|
||||
expect(Config.latest(entries, "default_agent")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("detects v1 configuration from any v1-only top-level key", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
@ -30,6 +30,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
@ -59,11 +60,15 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
providers: {
|
||||
custom: {
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
default: {
|
||||
name: "Default",
|
||||
},
|
||||
chat: {
|
||||
api: { id: "api-chat" },
|
||||
name: "Last",
|
||||
@ -106,6 +111,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
|
||||
expect(provider.enabled).toEqual({ via: "custom", data: {} })
|
||||
|
||||
@ -337,14 +337,50 @@ describe("SessionV2.create", () => {
|
||||
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
|
||||
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
|
||||
expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("switches the selected model through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
const model = ModelV2.Ref.make({
|
||||
id: ModelV2.ID.make("sonnet"),
|
||||
providerID: ProviderV2.ID.anthropic,
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
})
|
||||
|
||||
yield* session.switchModel({ sessionID: created.id, model })
|
||||
|
||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||
expect(
|
||||
yield* unavailable(
|
||||
session.switchModel({
|
||||
sessionID: created.id,
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }])
|
||||
|
||||
yield* session.switchModel({ sessionID: created.id, model })
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
|
||||
).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a model switch for a missing Session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const missing = SessionV2.ID.make("ses_missing_model_switch")
|
||||
|
||||
expect(
|
||||
yield* session
|
||||
.switchModel({
|
||||
sessionID: missing,
|
||||
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
|
||||
}),
|
||||
),
|
||||
).toBe("switchModel")
|
||||
})
|
||||
.pipe(
|
||||
Effect.flip,
|
||||
Effect.map((error) => error._tag),
|
||||
),
|
||||
).toBe("Session.NotFoundError")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user