From 657018be8f23610f604f11dab8a2fb8d1c51fdf7 Mon Sep 17 00:00:00 2001 From: Ziyuan Zhao Date: Sat, 16 May 2026 15:26:17 +0800 Subject: [PATCH 1/3] Fix provider setup refresh --- .../Agent/BridgeAIProviderSecretStore.swift | 8 +++++ .../ChatEditorViewModel+ComposerEditing.swift | 31 ++++++++++++++----- .../Interface/Chat/ChatEditorViewModel.swift | 14 +++++++++ .../Notifications/NotificationNames.swift | 4 +++ 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift b/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift index 1e258ee..8933d1b 100644 --- a/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift +++ b/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift @@ -36,6 +36,7 @@ nonisolated enum BridgeAIProviderSecretStore { store.settings = settings try saveStore(store) }.value + await notifyAIProviderStoreDidChange() } static func readSecret( @@ -64,6 +65,7 @@ nonisolated enum BridgeAIProviderSecretStore { store.secrets[account] = trimmed try saveStore(store) }.value + await notifyAIProviderStoreDidChange() } static func hasSecret( @@ -78,6 +80,12 @@ 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 { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser diff --git a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift index 9e0f71d..3d74399 100644 --- a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift +++ b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift @@ -153,13 +153,7 @@ extension ChatEditorViewModel { func loadSelectedModel() async { var settings = await BridgeAIProviderSecretStore.readSettings() availableModelGroups = BridgeAIProviderRegistry.availableModelsByProvider(settings: settings) - if settings.selectedModelProvider == "openai", - settings[.openAI].authMethod == .oauth, - BridgeAIProviderRegistry.displayModel(provider: "openai-codex", id: settings.selectedModelID) != nil - { - settings.selectedModelProvider = "openai-codex" - try? await BridgeAIProviderSecretStore.saveSettings(settings) - } + settings = await normalizeSelectedModelIfNeeded(settings) availableModelGroups = BridgeAIProviderRegistry.availableModelsByProvider(settings: settings) guard hasAvailableModelSelection else { selectedModelProvider = settings.selectedModelProvider @@ -180,6 +174,29 @@ extension ChatEditorViewModel { selectedModelID = selected.id } + private func normalizeSelectedModelIfNeeded(_ settings: BridgeAIProviderSettings) async -> BridgeAIProviderSettings { + guard settings.selectedModelProvider == "openai", + settings[.openAI].authMethod == .oauth, + settings[.openAI].isEnabled + else { + return settings + } + + let openAICodexModels = BridgeAIProviderRegistry.availableModels(settings: settings) + .filter { $0.provider == "openai-codex" } + guard let selected = openAICodexModels.first(where: { $0.id == settings.selectedModelID }) + ?? openAICodexModels.first + else { + return settings + } + + var nextSettings = settings + nextSettings.selectedModelProvider = selected.provider + nextSettings.selectedModelID = selected.id + try? await BridgeAIProviderSecretStore.saveSettings(nextSettings) + return nextSettings + } + private func updateSelectedModel(_ selectionID: String) { guard let parsed = Self.parseModelSelectionID(selectionID), availableModelGroups.contains(where: { group in diff --git a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift index 18e98a6..f808534 100644 --- a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift +++ b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift @@ -66,6 +66,8 @@ final class ChatEditorViewModel { var voicePendingWaveformPeak: Double = 0 @ObservationIgnored private var quoteFocusRequestCounter: Int = 0 + @ObservationIgnored + private var aiProviderSettingsCancellable: AnyCancellable? /// Skill selected for the current conversation (delegated to Chat) var selectedSkill: Skill? { @@ -125,9 +127,21 @@ final class ChatEditorViewModel { text: String = "" ) { self.text = text + setupAIProviderSettingsObservation() Task { await loadSelectedModel() } } + private func setupAIProviderSettingsObservation() { + aiProviderSettingsCancellable = NotificationCenter.default + .publisher(for: .aiProviderSettingsDidChange) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + Task { [weak self] in + await self?.loadSelectedModel() + } + } + } + // MARK: - State Helpers var hasRunningAgentTask: Bool { diff --git a/macos/OpenBridge/Notifications/NotificationNames.swift b/macos/OpenBridge/Notifications/NotificationNames.swift index cb19a70..37387fe 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 + + nonisolated static let aiProviderSettingsDidChange = Notification.Name("aiProviderSettingsDidChange") + // MARK: - Shortcuts static let keyboardShortcutsShortcutDidChange = Notification.Name("KeyboardShortcuts_shortcutByNameDidChange") From ba2027a2529614e72178a4e373cc4b9e82b13fef Mon Sep 17 00:00:00 2001 From: Ziyuan Zhao Date: Sat, 16 May 2026 15:48:15 +0800 Subject: [PATCH 2/3] Apply provider refresh review cleanup --- .../ChatEditorViewModel+ComposerEditing.swift | 31 +++++-------------- .../Notifications/NotificationNames.swift | 2 +- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift index 3d74399..9e0f71d 100644 --- a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift +++ b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel+ComposerEditing.swift @@ -153,7 +153,13 @@ extension ChatEditorViewModel { func loadSelectedModel() async { var settings = await BridgeAIProviderSecretStore.readSettings() availableModelGroups = BridgeAIProviderRegistry.availableModelsByProvider(settings: settings) - settings = await normalizeSelectedModelIfNeeded(settings) + if settings.selectedModelProvider == "openai", + settings[.openAI].authMethod == .oauth, + BridgeAIProviderRegistry.displayModel(provider: "openai-codex", id: settings.selectedModelID) != nil + { + settings.selectedModelProvider = "openai-codex" + try? await BridgeAIProviderSecretStore.saveSettings(settings) + } availableModelGroups = BridgeAIProviderRegistry.availableModelsByProvider(settings: settings) guard hasAvailableModelSelection else { selectedModelProvider = settings.selectedModelProvider @@ -174,29 +180,6 @@ extension ChatEditorViewModel { selectedModelID = selected.id } - private func normalizeSelectedModelIfNeeded(_ settings: BridgeAIProviderSettings) async -> BridgeAIProviderSettings { - guard settings.selectedModelProvider == "openai", - settings[.openAI].authMethod == .oauth, - settings[.openAI].isEnabled - else { - return settings - } - - let openAICodexModels = BridgeAIProviderRegistry.availableModels(settings: settings) - .filter { $0.provider == "openai-codex" } - guard let selected = openAICodexModels.first(where: { $0.id == settings.selectedModelID }) - ?? openAICodexModels.first - else { - return settings - } - - var nextSettings = settings - nextSettings.selectedModelProvider = selected.provider - nextSettings.selectedModelID = selected.id - try? await BridgeAIProviderSecretStore.saveSettings(nextSettings) - return nextSettings - } - private func updateSelectedModel(_ selectionID: String) { guard let parsed = Self.parseModelSelectionID(selectionID), availableModelGroups.contains(where: { group in diff --git a/macos/OpenBridge/Notifications/NotificationNames.swift b/macos/OpenBridge/Notifications/NotificationNames.swift index 37387fe..6f42582 100644 --- a/macos/OpenBridge/Notifications/NotificationNames.swift +++ b/macos/OpenBridge/Notifications/NotificationNames.swift @@ -17,7 +17,7 @@ extension Notification.Name { // MARK: - AI Providers - nonisolated static let aiProviderSettingsDidChange = Notification.Name("aiProviderSettingsDidChange") + static let aiProviderSettingsDidChange = Notification.Name("aiProviderSettingsDidChange") // MARK: - Shortcuts From 54f22623ce96f15aefa7918195db27b328f13199 Mon Sep 17 00:00:00 2001 From: EYHN Date: Mon, 18 May 2026 16:50:40 +0800 Subject: [PATCH 3/3] Fix provider settings composer reload race --- .../Agent/BridgeAIProviderSecretStore.swift | 31 +++++++++++ .../ChatEditorViewModel+ComposerEditing.swift | 11 ++++ .../Interface/Chat/ChatEditorViewModel.swift | 23 +++++++-- ...EditorViewModelProviderSettingsTests.swift | 51 +++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 macos/OpenBridgeUnitTests/Suites/ChatEditorViewModelProviderSettingsTests.swift diff --git a/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift b/macos/OpenBridge/Agent/BridgeAIProviderSecretStore.swift index 8933d1b..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] = [:] @@ -87,6 +106,10 @@ nonisolated enum BridgeAIProviderSecretStore { } 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) @@ -96,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 f808534..5ecf740 100644 --- a/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift +++ b/macos/OpenBridge/Interface/Chat/ChatEditorViewModel.swift @@ -68,6 +68,10 @@ final class ChatEditorViewModel { 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? { @@ -128,7 +132,7 @@ final class ChatEditorViewModel { ) { self.text = text setupAIProviderSettingsObservation() - Task { await loadSelectedModel() } + scheduleSelectedModelReload() } private func setupAIProviderSettingsObservation() { @@ -136,12 +140,23 @@ final class ChatEditorViewModel { .publisher(for: .aiProviderSettingsDidChange) .receive(on: DispatchQueue.main) .sink { [weak self] _ in - Task { [weak self] in - await self?.loadSelectedModel() - } + 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 var hasRunningAgentTask: Bool { 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) + } + } +}