Browse Source

feat(core): honor default session models (#30982)

Kit Langton 2 months ago
parent
commit
d2204e0ff5

+ 6 - 0
packages/core/src/config.ts

@@ -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[]>

+ 1 - 2
packages/core/src/config/plugin/agent.ts

@@ -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))

+ 7 - 1
packages/core/src/config/plugin/provider.ts

@@ -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)

+ 15 - 3
packages/core/src/session.ts

@@ -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)

+ 1 - 2
packages/core/src/session/runner/model.ts

@@ -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))

+ 14 - 0
packages/core/test/config/config.test.ts

@@ -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)

+ 7 - 1
packages/core/test/config/provider.test.ts

@@ -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: {} })

+ 42 - 6
packages/core/test/session-create.test.ts

@@ -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(
+        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* unavailable(
-          session.switchModel({
-            sessionID: created.id,
+        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")
     }),
   )
 })