diff --git a/Sources/Bloom/State/AppModel+LastModelChoice.swift b/Sources/Bloom/State/AppModel+LastModelChoice.swift new file mode 100644 index 000000000..1221a2751 --- /dev/null +++ b/Sources/Bloom/State/AppModel+LastModelChoice.swift @@ -0,0 +1,15 @@ +import BloomCore +import Foundation + +/// Remembering the model somebody picked, so the next workspace opens on it. See `LastModelChoice` +/// for where it ranks against Settings and a default preset. +extension AppModel { + /// Called by every footer when its controls change. Only a move of the model, the effort or + /// the backend is written: the footer also reports fast mode, the output style and the + /// permission mode, and none of those is a model choice. + func rememberModelChoice(from old: ComposerControls, to new: ComposerControls) { + let choice = LastModelChoice(new) + guard choice != LastModelChoice(old), let store else { return } + Task { try? await choice.save(to: store) } + } +} diff --git a/Sources/Bloom/State/AppModel+Workspaces.swift b/Sources/Bloom/State/AppModel+Workspaces.swift index 023a7ce98..c5a23344e 100644 --- a/Sources/Bloom/State/AppModel+Workspaces.swift +++ b/Sources/Bloom/State/AppModel+Workspaces.swift @@ -321,7 +321,9 @@ extension AppModel { func resolvedControls(for repo: Repo?) async throws -> ComposerControls { guard let store else { return ComposerControls() } - let appDefaults = await AppDefaults.loadForNewSessions(from: store) + let appDefaults = await AppDefaults.loadForNewSessions( + from: store, models: ComposerModelCatalog.shared.models + ) let repoSettings = await Task.detached(priority: .userInitiated) { repo.map { SettingsLoader.load(repo: $0.path) } ?? RepoSettings() }.value diff --git a/Sources/Bloom/State/WorkspaceModel.swift b/Sources/Bloom/State/WorkspaceModel.swift index f3aa2a53b..1b0475e40 100644 --- a/Sources/Bloom/State/WorkspaceModel.swift +++ b/Sources/Bloom/State/WorkspaceModel.swift @@ -537,7 +537,9 @@ final class WorkspaceModel { usesCLI suppliedCLIChoice: Bool? ) async -> PaneContent? { guard let store, let repo = app.repo(for: workspace) else { return nil } - let defaults = await AppDefaults.loadForNewSessions(from: store) + let defaults = await AppDefaults.loadForNewSessions( + from: store, models: ComposerModelCatalog.shared.models + ) let controls = suppliedControls ?? ComposerControls( defaults: ComposerDefaults.resolve( repo: SettingsLoader.load(repo: workspace.path), app: defaults, diff --git a/Sources/Bloom/Views/Center/Composer/ComposerView.swift b/Sources/Bloom/Views/Center/Composer/ComposerView.swift index 2c47eaa68..7e7085713 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerView.swift @@ -309,6 +309,7 @@ struct ComposerView: View { /// Writes the footer's choices back where a conversation keeps them: the four that are columns /// go on the session row, and fast mode and the output style go in the store's key value table. private func apply(controls new: ComposerControls) { + app.rememberModelChoice(from: controls, to: new) if new.codexFastMode != codexFastMode { codexFastMode = new.codexFastMode @@ -783,7 +784,9 @@ struct ComposerView: View { let wasPrepared = (try? await store.setting(appliedKey)) == "1" guard !wasPrepared, transcript.session.agentSessionID == nil else { return } - let appDefaults = await AppDefaults.loadForNewSessions(from: store) + let appDefaults = await AppDefaults.loadForNewSessions( + from: store, models: ComposerModelCatalog.shared.models + ) // Off the main actor because it reads up to six files from disk. var repoSettings = RepoSettings() diff --git a/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift b/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift index 8927c573d..240390bc3 100644 --- a/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift +++ b/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift @@ -570,7 +570,10 @@ struct CreateWorkspaceView: View { ) { actions in ComposerFooterView( controls: controls, - onChange: { controls = $0 }, + onChange: { new in + app.rememberModelChoice(from: controls, to: new) + controls = new + }, canSend: canCreate, intent: .create, // This window's width is fixed and was chosen for this row with its words on, so @@ -937,7 +940,9 @@ struct CreateWorkspaceView: View { let path = repo.path var appDefaults = AppDefaults() if let store = app.store { - appDefaults = await AppDefaults.loadForNewSessions(from: store) + appDefaults = await AppDefaults.loadForNewSessions( + from: store, models: ComposerModelCatalog.shared.models + ) } // The gathering and both branch decisions live in the core, where the suite can reach diff --git a/Sources/BloomCore/Agent/ComposerDefaults.swift b/Sources/BloomCore/Agent/ComposerDefaults.swift index 57dcfb260..7aefda157 100644 --- a/Sources/BloomCore/Agent/ComposerDefaults.swift +++ b/Sources/BloomCore/Agent/ComposerDefaults.swift @@ -86,6 +86,18 @@ public struct ComposerDefaults: Equatable { running: running, models: models ) + // A model the signed in account cannot run is never the answer, whichever layer named it. + // Everything that pinned a model is set aside and the built-ins answer instead, because a + // chat that opens on a model the server refuses is worse than one on a plainer default. + guard ModelAvailability.isUsable(model: resolved.model, on: resolved.kind, models: models) else { + return resolve( + repo: repo.withoutModelPins(), + app: app.withBuiltInModel(), + hasWorktree: hasWorktree, + running: running, + models: models + ) + } return ComposerDefaults( // `resolved.model` rather than the string the file held. A settings file has to name a // model for either CLI in one key, so it can write the backend in front of it, and @@ -118,3 +130,26 @@ public struct ComposerDefaults: Equatable { return fallback } } + +private extension RepoSettings { + func withoutModelPins() -> RepoSettings { + var next = self + next.defaultModel = nil + next.defaultEffort = nil + next.homeDefaultModel = nil + next.homeDefaultEffort = nil + return next + } +} + +private extension AppDefaults { + func withBuiltInModel() -> AppDefaults { + var next = self + next.model = AppDefaults.fallbackModel + next.storedModel = nil + next.effort = AppDefaults.fallbackEffort + next.storedEffort = nil + next.backend = AppDefaults.fallbackBackend + return next + } +} diff --git a/Sources/BloomCore/Agent/LastModelChoice.swift b/Sources/BloomCore/Agent/LastModelChoice.swift new file mode 100644 index 000000000..e490031fe --- /dev/null +++ b/Sources/BloomCore/Agent/LastModelChoice.swift @@ -0,0 +1,80 @@ +import Foundation + +/// The model, effort and backend somebody last picked in a composer footer, kept so the next +/// workspace opens on it rather than on whatever Settings says. +/// +/// **It sits between the two things that already exist.** A default preset is a statement that +/// every new chat should start there, so it still wins. Settings, Models is the standing default +/// and this overrides it, because a choice made a minute ago is better evidence of what somebody +/// wants than a choice made at onboarding. A repository's own `models.default` still outranks +/// both, which `ComposerDefaults.resolve` already guarantees by reading the repository first. +/// +/// Only the three fields that name a model are kept. The permission mode and the output style are +/// about how a chat behaves rather than what runs it, and carrying them from one workspace to the +/// next would quietly widen what an agent is allowed to do. +public struct LastModelChoice: Codable, Equatable, Sendable { + public static let key = "composer.lastModelChoice" + + public var model: String + public var effort: String + /// Stored beside the model for the reason `AppDefaults.backend` is: Codex's list is fetched, + /// and a new workspace has to open on the right CLI before, or without, that fetch. + public var backend: AgentKind + + public init(model: String, effort: String, backend: AgentKind) { + self.model = model + self.effort = effort + self.backend = backend + } + + public init(_ controls: ComposerControls) { + self.init(model: controls.model, effort: controls.effort, backend: controls.agentKind) + } + + public static func load(from store: Store) async -> LastModelChoice? { + guard let raw = try? await store.setting(key), let data = raw.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(LastModelChoice.self, from: data) + } + + public func save(to store: Store) async throws { + let data = try JSONEncoder().encode(self) + try await store.setSetting(Self.key, String(decoding: data, as: UTF8.self)) + } +} + +public extension AppDefaults { + /// These defaults with the last footer choice written over the model, the effort and the + /// backend. The stored fields move too, for the reason `applying(_ preset:)` gives: they are + /// what `ComposerDefaults.resolve` reads to decide whether anything was chosen. + func applying(_ choice: LastModelChoice) -> AppDefaults { + var next = self + next.model = choice.model + next.storedModel = choice.model + next.effort = choice.effort + next.storedEffort = choice.effort + next.backend = choice.backend + return next + } +} + +/// Whether a model can be offered to somebody who may not have it. +/// +/// **Only Codex can be asked, and only once its list has arrived.** Codex's `model/list` is +/// authoritative for its own ids, and a model that is not on the list the signed in account +/// fetched is one the account cannot run. Claude Code's ids cannot be checked the same way: +/// `opus-5-1m` is a real id no list holds. And an empty list means the fetch has not answered, not +/// that nothing is allowed, so it never disqualifies anything: a default that changed while a list +/// loads would be worse than one that was occasionally wrong. +public enum ModelAvailability { + public static func isUsable( + model: String, + on kind: AgentKind, + models: [AgentKind: [AgentModel]] + ) -> Bool { + guard kind == .codex, let list = models[kind], !list.isEmpty else { return true } + let id = ModelIdentifier.resolve(model, models: models).model + return list.contains { $0.id == id } + } +} diff --git a/Sources/BloomCore/Agent/ModelPreset.swift b/Sources/BloomCore/Agent/ModelPreset.swift index 1bf03c1f4..37d564271 100644 --- a/Sources/BloomCore/Agent/ModelPreset.swift +++ b/Sources/BloomCore/Agent/ModelPreset.swift @@ -312,11 +312,22 @@ public extension AppDefaults { } /// What a brand new session inherits: the Settings defaults, with the default preset over - /// them when there is one. Settings itself reads `load`, so the screen keeps showing and - /// saving its own values rather than the preset's. - static func loadForNewSessions(from store: Store) async -> AppDefaults { + /// them when there is one, and otherwise the last model chosen in a footer. Settings itself + /// reads `load`, so the screen keeps showing and saving its own values rather than these. + /// + /// A last choice the account cannot run is skipped rather than applied, so the next layer down + /// answers instead of a chat opening on a model that refuses it. See `ModelAvailability`. + static func loadForNewSessions( + from store: Store, + models: [AgentKind: [AgentModel]] = [:] + ) async -> AppDefaults { let defaults = await load(from: store) - guard let preset = await ModelPresetList.load(from: store).defaultPreset else { return defaults } - return defaults.applying(preset) + if let preset = await ModelPresetList.load(from: store).defaultPreset { + return defaults.applying(preset) + } + guard let last = await LastModelChoice.load(from: store), + ModelAvailability.isUsable(model: last.model, on: last.backend, models: models) + else { return defaults } + return defaults.applying(last) } } diff --git a/Tests/BloomCoreTests/LastModelChoiceTests.swift b/Tests/BloomCoreTests/LastModelChoiceTests.swift new file mode 100644 index 000000000..ce333a10f --- /dev/null +++ b/Tests/BloomCoreTests/LastModelChoiceTests.swift @@ -0,0 +1,102 @@ +import Testing +import Foundation +@testable import BloomCore + +/// The last footer choice feeds the next workspace, ranks below a default preset and a repository +/// pin, and never lands a chat on a Codex model the account cannot run. +@Suite("Last model choice", .scratchDirectory) +struct LastModelChoiceTests { + static let sol = LastModelChoice(model: "gpt-5.6-sol", effort: "low", backend: .codex) + + /// What the signed in account's `model/list` answered, which does not include `gpt-5.5`. + static let accountModels: [AgentKind: [AgentModel]] = [ + .codex: [AgentModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", isDefault: true)], + ] + + @Test func aChoiceSurvivesTheStore() async throws { + let store = try makeTestStore("last-model-choice-round-trip") + #expect(await LastModelChoice.load(from: store) == nil) + try await Self.sol.save(to: store) + #expect(await LastModelChoice.load(from: store) == Self.sol) + } + + @Test func theLastChoiceIsWhatANewSessionStartsOn() async throws { + let store = try makeTestStore("last-model-choice-new-session") + await AppDefaults(model: "sonnet", effort: "low").save(to: store) + try await Self.sol.save(to: store) + + // Settings keeps showing its own values. + #expect(await AppDefaults.load(from: store).model == "sonnet") + + let defaults = await AppDefaults.loadForNewSessions(from: store) + let resolved = ComposerDefaults.resolve(repo: RepoSettings(), app: defaults) + #expect(resolved.model == "gpt-5.6-sol") + #expect(resolved.backend == .codex) + #expect(resolved.effort == "low") + } + + @Test func aDefaultPresetOutranksTheLastChoice() async throws { + let store = try makeTestStore("last-model-choice-preset") + try await Self.sol.save(to: store) + let opus = ModelPreset( + name: "Opus", model: "opus", effort: "high", backend: .claudeCode, + permissionMode: .bypassPermissions + ) + var list = ModelPresetList(presets: [opus]) + list.setDefault(opus.id) + try await list.save(to: store) + + #expect(await AppDefaults.loadForNewSessions(from: store).model == "opus") + } + + @Test func aRepositoryPinOutranksTheLastChoice() async throws { + let store = try makeTestStore("last-model-choice-repo") + try await Self.sol.save(to: store) + var repo = RepoSettings() + repo.defaultModel = "sonnet" + + let defaults = await AppDefaults.loadForNewSessions(from: store) + #expect(ComposerDefaults.resolve(repo: repo, app: defaults).model == "sonnet") + } + + @Test func aLastChoiceTheAccountCannotRunFallsBackToSettings() async throws { + let store = try makeTestStore("last-model-choice-unusable") + await AppDefaults(model: "sonnet", effort: "low").save(to: store) + try await LastModelChoice(model: "gpt-5.5", effort: "medium", backend: .codex).save(to: store) + + let defaults = await AppDefaults.loadForNewSessions(from: store, models: Self.accountModels) + #expect(defaults.model == "sonnet") + } + + @Test func aPinnedModelTheAccountCannotRunFallsBackToTheBuiltIn() { + var repo = RepoSettings() + repo.defaultModel = "gpt-5.5" + + let resolved = ComposerDefaults.resolve( + repo: repo, + app: AppDefaults(model: "gpt-5.5", backend: .codex), + models: Self.accountModels + ) + #expect(resolved.model == AppDefaults.fallbackModel) + #expect(resolved.backend == .claudeCode) + } + + @Test func aModelOnTheListIsKept() { + let resolved = ComposerDefaults.resolve( + repo: RepoSettings(), + app: AppDefaults(model: "gpt-5.6-sol", effort: "low", backend: .codex), + models: Self.accountModels + ) + #expect(resolved.model == "gpt-5.6-sol") + #expect(resolved.backend == .codex) + } + + /// An empty list means the fetch has not answered, which is not the same as nothing allowed. + @Test func anUnfetchedListDisqualifiesNothing() { + #expect(ModelAvailability.isUsable(model: "gpt-5.5", on: .codex, models: [:])) + #expect(ModelAvailability.isUsable(model: "gpt-5.5", on: .codex, models: [.codex: []])) + #expect(!ModelAvailability.isUsable(model: "gpt-5.5", on: .codex, models: Self.accountModels)) + // Claude Code ids cannot be checked against a list, so they are never refused. + #expect(ModelAvailability.isUsable(model: "opus-5-1m", on: .claudeCode, models: Self.accountModels)) + } +}