Skip to content

Commit 0b0a607

Browse files
authored
feat(core): honor default session models (#30982)
1 parent 3392319 commit 0b0a607

8 files changed

Lines changed: 93 additions & 15 deletions

File tree

‎packages/core/src/config.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
118118

119119
export type Entry = Document | Directory
120120

121+
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
122+
return entries
123+
.filter((entry): entry is Document => entry.type === "document")
124+
.findLast((entry) => entry.info[key] !== undefined)?.info[key]
125+
}
126+
121127
export interface Interface {
122128
/** Returns location config documents and supplemental directories from lowest to highest priority. */
123129
readonly entries: () => Effect.Effect<Entry[]>

‎packages/core/src/config/plugin/agent.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,7 @@ export const Plugin = PluginV2.define({
5858

5959
yield* agent.update((editor) => {
6060
const global = documents.flatMap((document) => document.info.permissions ?? [])
61-
const configuredDefault = documents.findLast((document) => document.info.default_agent !== undefined)?.info
62-
.default_agent
61+
const configuredDefault = Config.latest(documents, "default_agent")
6362
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
6463
for (const current of editor.list()) {
6564
editor.update(current.id, (agent) => agent.permissions.push(...global))

‎packages/core/src/config/plugin/provider.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ export const Plugin = PluginV2.define({
1313
const catalog = yield* Catalog.Service
1414
const config = yield* Config.Service
1515
const transform = yield* catalog.transform()
16-
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
16+
const entries = yield* config.entries()
17+
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
1718

1819
yield* transform((catalog) => {
20+
const configuredDefault = Config.latest(entries, "model")
21+
if (configuredDefault !== undefined) {
22+
const model = ModelV2.parse(configuredDefault)
23+
catalog.model.default.set(model.providerID, model.modelID)
24+
}
1925
for (const file of files) {
2026
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
2127
const providerID = ProviderV2.ID.make(id)

‎packages/core/src/session.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export interface Interface {
132132
readonly switchModel: (input: {
133133
sessionID: SessionSchema.ID
134134
model: ModelV2.Ref
135-
}) => Effect.Effect<void, OperationUnavailableError>
135+
}) => Effect.Effect<void, NotFoundError>
136136
readonly prompt: (input: {
137137
id?: SessionMessage.ID
138138
sessionID: SessionSchema.ID
@@ -385,8 +385,20 @@ export const layer = Layer.effect(
385385
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
386386
return yield* new OperationUnavailableError({ operation: "switchAgent" })
387387
}),
388-
switchModel: Effect.fn("V2Session.switchModel")(function* () {
389-
return yield* new OperationUnavailableError({ operation: "switchModel" })
388+
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
389+
const session = yield* result.get(input.sessionID)
390+
if (
391+
session.model?.providerID === input.model.providerID &&
392+
session.model.id === input.model.id &&
393+
(session.model.variant ?? "default") === (input.model.variant ?? "default")
394+
)
395+
return
396+
yield* events.publish(SessionEvent.ModelSwitched, {
397+
sessionID: input.sessionID,
398+
messageID: SessionMessage.ID.create(),
399+
timestamp: yield* DateTime.now,
400+
model: input.model,
401+
})
390402
}),
391403
compact: Effect.fn("V2Session.compact")(function* (input) {
392404
yield* result.get(input.sessionID)

‎packages/core/src/session/runner/model.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,9 @@ export const locationLayer = Layer.effect(
128128
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
129129
// Location plugins populate and filter the catalog asynchronously during layer startup.
130130
yield* boot.wait()
131-
const preferred = yield* catalog.model.default()
132131
const selected = session.model
133132
? yield* catalog.model.get(session.model.providerID, session.model.id)
134-
: (Option.getOrUndefined(preferred.pipe(Option.filter(supported))) ??
133+
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
135134
(yield* catalog.model.available()).find(supported))
136135
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
137136
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))

‎packages/core/test/config/config.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ const provider = {
5252
}
5353

5454
describe("Config", () => {
55+
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
56+
Effect.sync(() => {
57+
const entries = [
58+
new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5" }) }),
59+
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
60+
new Config.Document({ type: "document", info: new Config.Info({}) }),
61+
new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5.5" }) }),
62+
]
63+
64+
expect(Config.latest(entries, "model")).toBe("openrouter/openai/gpt-5.5")
65+
expect(Config.latest(entries, "default_agent")).toBeUndefined()
66+
}),
67+
)
68+
5569
it.effect("detects v1 configuration from any v1-only top-level key", () =>
5670
Effect.sync(() => {
5771
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)

‎packages/core/test/config/provider.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect } from "bun:test"
2-
import { Effect, Schema } from "effect"
2+
import { Effect, Option, Schema } from "effect"
33
import { Catalog } from "@opencode-ai/core/catalog"
44
import { Config } from "@opencode-ai/core/config"
55
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
@@ -30,6 +30,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
3030
new Config.Document({
3131
type: "document",
3232
info: decode({
33+
model: "custom/first",
3334
providers: {
3435
custom: {
3536
name: "Configured",
@@ -59,11 +60,15 @@ describe("ConfigProviderPlugin.Plugin", () => {
5960
new Config.Document({
6061
type: "document",
6162
info: decode({
63+
model: "custom/default",
6264
providers: {
6365
custom: {
6466
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
6567
request: request({ last: "last", shared: "last" }),
6668
models: {
69+
default: {
70+
name: "Default",
71+
},
6772
chat: {
6873
api: { id: "api-chat" },
6974
name: "Last",
@@ -106,6 +111,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
106111

107112
const provider = yield* catalog.provider.get(providerID)
108113
const model = yield* catalog.model.get(providerID, modelID)
114+
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
109115
expect(provider.name).toBe("Renamed")
110116
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
111117
expect(provider.enabled).toEqual({ via: "custom", data: {} })

‎packages/core/test/session-create.test.ts‎

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,14 +337,50 @@ describe("SessionV2.create", () => {
337337
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
338338
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
339339
expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
340+
}),
341+
)
342+
343+
it.effect("switches the selected model through the durable Session event", () =>
344+
Effect.gen(function* () {
345+
const session = yield* SessionV2.Service
346+
const created = yield* session.create({ location })
347+
const model = ModelV2.Ref.make({
348+
id: ModelV2.ID.make("sonnet"),
349+
providerID: ProviderV2.ID.anthropic,
350+
variant: ModelV2.VariantID.make("high"),
351+
})
352+
353+
yield* session.switchModel({ sessionID: created.id, model })
354+
355+
expect(yield* session.get(created.id)).toMatchObject({ model })
356+
expect(
357+
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
358+
).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }])
359+
360+
yield* session.switchModel({ sessionID: created.id, model })
361+
const { db } = yield* Database.Service
362+
expect(
363+
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
364+
).toHaveLength(2)
365+
}),
366+
)
367+
368+
it.effect("rejects a model switch for a missing Session", () =>
369+
Effect.gen(function* () {
370+
const session = yield* SessionV2.Service
371+
const missing = SessionV2.ID.make("ses_missing_model_switch")
372+
340373
expect(
341-
yield* unavailable(
342-
session.switchModel({
343-
sessionID: created.id,
374+
yield* session
375+
.switchModel({
376+
sessionID: missing,
344377
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
345-
}),
346-
),
347-
).toBe("switchModel")
378+
})
379+
.pipe(
380+
Effect.flip,
381+
Effect.map((error) => error._tag),
382+
),
383+
).toBe("Session.NotFoundError")
348384
}),
349385
)
350386
})

0 commit comments

Comments
 (0)