diff --git a/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift b/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift index 1e258ee..efb05a1 100644 --- a/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift +++ b/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift @@ -6,7 +6,26 @@ nonisolated enum BridgeAIProviderSecretKind: String, Sendable { case oauthRefreshToken } +private final nonisolated class BridgeAIProviderStoreURLOverride: @unchecked Sendable { + private let lock = NSLock() + private var url: URL? + + func set(_ url: URL?) { + lock.withLock { + self.url = url + } + } + + func get() -> URL? { + lock.withLock { + url + } + } +} + nonisolated enum BridgeAIProviderSecretStore { + private static let storeURLOverride = BridgeAIProviderStoreURLOverride() + private struct StoreFile: Codable { var settings = BridgeAIProviderSettings() var secrets: [String: String] = [:] @@ -36,6 +55,7 @@ nonisolated enum BridgeAIProviderSecretStore { store.settings = settings try saveStore(store) }.value + await notifyAIProviderStoreDidChange() } static func readSecret( @@ -64,6 +84,7 @@ nonisolated enum BridgeAIProviderSecretStore { store.secrets[account] = trimmed try saveStore(store) }.value + await notifyAIProviderStoreDidChange() } static func hasSecret( @@ -78,7 +99,17 @@ nonisolated enum BridgeAIProviderSecretStore { "\(provider.rawValue).\(kind.rawValue)" } + private static func notifyAIProviderStoreDidChange() async { + await MainActor.run { + NotificationCenter.default.post(name: .aiProviderSettingsDidChange, object: nil) + } + } + private static var storeURL: URL { + if let override = resolvedStoreURLOverride { + return override + } + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support", isDirectory: true) @@ -88,6 +119,14 @@ nonisolated enum BridgeAIProviderSecretStore { .appendingPathComponent("secrets.json", isDirectory: false) } + static func setStoreURLForTesting(_ url: URL?) { + storeURLOverride.set(url) + } + + private static var resolvedStoreURLOverride: URL? { + storeURLOverride.get() + } + private static func loadStore() throws -> StoreFile { let url = storeURL guard FileManager.default.fileExists(atPath: url.path) else { diff --git a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift index 9e0f71d..ccebd21 100644 --- a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift +++ b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift @@ -151,7 +151,17 @@ extension ChatEditorViewModel { } func loadSelectedModel() async { + await loadSelectedModel(isCurrentReload: { true }) + } + + func loadSelectedModelIfCurrent(generation: UInt64) async { + await loadSelectedModel(isCurrentReload: { self.isCurrentSelectedModelReload(generation: generation) }) + } + + private func loadSelectedModel(isCurrentReload: () -> Bool) async { var settings = await BridgeAIProviderSecretStore.readSettings() + guard isCurrentReload() else { return } + availableModelGroups = BridgeAIProviderRegistry.availableModelsByProvider(settings: settings) if settings.selectedModelProvider == "openai", settings[.openAI].authMethod == .oauth, @@ -159,6 +169,7 @@ extension ChatEditorViewModel { { settings.selectedModelProvider = "openai-codex" try? await BridgeAIProviderSecretStore.saveSettings(settings) + guard isCurrentReload() else { return } } availableModelGroups = BridgeAIProviderRegistry.availableModelsByProvider(settings: settings) guard hasAvailableModelSelection else { diff --git a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift index 18e98a6..5ecf740 100644 --- a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift +++ b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift @@ -66,6 +66,12 @@ final class ChatEditorViewModel { var voicePendingWaveformPeak: Double = 0 @ObservationIgnored private var quoteFocusRequestCounter: Int = 0 + @ObservationIgnored + private var aiProviderSettingsCancellable: AnyCancellable? + @ObservationIgnored + private var aiProviderSettingsReloadTask: Task? + @ObservationIgnored + private var aiProviderSettingsReloadGeneration: UInt64 = 0 /// Skill selected for the current conversation (delegated to Chat) var selectedSkill: Skill? { @@ -125,7 +131,30 @@ final class ChatEditorViewModel { text: String = "" ) { self.text = text - Task { await loadSelectedModel() } + setupAIProviderSettingsObservation() + scheduleSelectedModelReload() + } + + private func setupAIProviderSettingsObservation() { + aiProviderSettingsCancellable = NotificationCenter.default + .publisher(for: .aiProviderSettingsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.scheduleSelectedModelReload() + } + } + + private func scheduleSelectedModelReload() { + aiProviderSettingsReloadGeneration += 1 + let generation = aiProviderSettingsReloadGeneration + aiProviderSettingsReloadTask?.cancel() + aiProviderSettingsReloadTask = Task { [weak self] in + await self?.loadSelectedModelIfCurrent(generation: generation) + } + } + + func isCurrentSelectedModelReload(generation: UInt64) -> Bool { + !Task.isCancelled && generation == aiProviderSettingsReloadGeneration } // MARK: - State Helpers diff --git a/macos/OpenBridge/Notifications/NotificationNames.swift b/macos/OpenBridge/Notifications/NotificationNames.swift index cb19a70..6f42582 100644 --- a/macos/OpenBridge/Notifications/NotificationNames.swift +++ b/macos/OpenBridge/Notifications/NotificationNames.swift @@ -15,6 +15,10 @@ extension Notification.Name { static let skillActivationRequested = Notification.Name("skillActivationRequested") static let skillInventoryDidChange = Notification.Name("skillInventoryDidChange") + // MARK: - AI Providers + + static let aiProviderSettingsDidChange = Notification.Name("aiProviderSettingsDidChange") + // MARK: - Shortcuts static let keyboardShortcutsShortcutDidChange = Notification.Name("KeyboardShortcuts_shortcutByNameDidChange") diff --git a/macos/OpenBridgeUnitTests/Suites/ChatEditorViewModelProviderSettingsTests.swift b/macos/OpenBridgeUnitTests/Suites/ChatEditorViewModelProviderSettingsTests.swift new file mode 100644 index 0000000..b2faed7 --- /dev/null +++ b/macos/OpenBridgeUnitTests/Suites/ChatEditorViewModelProviderSettingsTests.swift @@ -0,0 +1,51 @@ +import ComposerEditor +import Foundation +@testable import OpenBridge +import Testing + +@MainActor +struct ChatEditorViewModelProviderSettingsTests { + @Test + func `provider settings notification refreshes composer model state`() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("secrets.json", isDirectory: false) + BridgeAIProviderSecretStore.setStoreURLForTesting(storeURL) + defer { + BridgeAIProviderSecretStore.setStoreURLForTesting(nil) + try? FileManager.default.removeItem(at: storeURL.deletingLastPathComponent()) + } + + let viewModel = ChatEditorViewModel() + #expect(viewModel.hasAvailableModelSelection == false) + + var settings = BridgeAIProviderSettings(selectedModelProvider: "openai", selectedModelID: "gpt-5") + var openAIConfig = settings[.openAI] + openAIConfig.isEnabled = true + openAIConfig.authMethod = .apiKey + settings[.openAI] = openAIConfig + + try await BridgeAIProviderSecretStore.saveSettings(settings) + try await waitUntil { + viewModel.hasAvailableModelSelection + && viewModel.selectedModelProvider == "openai" + && viewModel.selectedModelID == "gpt-5" + } + + #expect(viewModel.composerRealModelSelectorConfig?.selectedModelTitle == "GPT-5") + } + + private func waitUntil( + timeout: Duration = .seconds(2), + condition: @MainActor @escaping () -> Bool + ) async throws { + let start = ContinuousClock.now + while !condition() { + if ContinuousClock.now - start > timeout { + Issue.record("Timed out waiting for condition.") + return + } + try await Task.sleep(nanoseconds: 50_000_000) + } + } +}