Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Sources/Bloom/State/AppModel+LastModelChoice.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
4 changes: 3 additions & 1 deletion Sources/Bloom/State/AppModel+Workspaces.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion Sources/Bloom/State/WorkspaceModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion Sources/Bloom/Views/Center/Composer/ComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 7 additions & 2 deletions Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions Sources/BloomCore/Agent/ComposerDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
80 changes: 80 additions & 0 deletions Sources/BloomCore/Agent/LastModelChoice.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
21 changes: 16 additions & 5 deletions Sources/BloomCore/Agent/ModelPreset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
102 changes: 102 additions & 0 deletions Tests/BloomCoreTests/LastModelChoiceTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading