diff --git a/Termind/App/SettingsKey.swift b/Termind/App/SettingsKey.swift index 0ea6fc9b..04672876 100644 --- a/Termind/App/SettingsKey.swift +++ b/Termind/App/SettingsKey.swift @@ -30,6 +30,8 @@ nonisolated enum SettingsKey { } /// Restore the previous window's tabs on launch. static let restoreTabs = "window.restoreTabs" + /// The last approval posture selected in the Assistant composer. + static let agentPermissionMode = "assistant.permissionMode" static let sftpGlobalTransferLimit = "sftp.transfers.globalLimit" static let sftpPerHostTransferLimit = "sftp.transfers.perHostLimit" static let defaultSFTPGlobalTransferLimit = 3 diff --git a/Termind/Core/Stores/AIStore.swift b/Termind/Core/Stores/AIStore.swift index 814b9e45..48d1aed8 100644 --- a/Termind/Core/Stores/AIStore.swift +++ b/Termind/Core/Stores/AIStore.swift @@ -27,8 +27,8 @@ final class AIStore { private(set) var lastSelectedModel: AIModelRef? /// The approval posture new and otherwise-unset conversations follow. - /// Session-only. Unlike the model selection, approval posture is not part - /// of provider configuration and is deliberately not persisted here. + /// Stored in local preferences rather than provider configuration so it + /// survives relaunches without becoming part of the synced SwiftData model. private(set) var lastSelectedPermissionMode: ChatPermissionMode /// Live MCP connection state, keyed by server. @@ -44,6 +44,7 @@ final class AIStore { private var secrets: [String: String] = [:] private let keychain: KeychainSecretStore private let persistsSecrets: Bool + private let permissionDefaults: UserDefaults? private(set) var secretStorageError: String? private var persistence: AIPersistence? private(set) var storageError: String? @@ -55,23 +56,33 @@ final class AIStore { lastSelectedModel: AIModelRef? = nil, lastSelectedPermissionMode: ChatPermissionMode = .askForApproval, keychain: KeychainSecretStore = KeychainSecretStore(), - persistsSecrets: Bool = true + persistsSecrets: Bool = true, + permissionDefaults: UserDefaults? = nil ) { self.modelProviders = modelProviders self.mcpServers = mcpServers self.roles = roles self.lastSelectedModel = lastSelectedModel - self.lastSelectedPermissionMode = lastSelectedPermissionMode + self.lastSelectedPermissionMode = permissionDefaults + .flatMap { $0.string(forKey: SettingsKey.agentPermissionMode) } + .flatMap(ChatPermissionMode.init(rawValue:)) + ?? lastSelectedPermissionMode self.keychain = keychain self.persistsSecrets = persistsSecrets + self.permissionDefaults = permissionDefaults } convenience init( modelContext: ModelContext, keychain: KeychainSecretStore = KeychainSecretStore(), - persistsSecrets: Bool = true + persistsSecrets: Bool = true, + permissionDefaults: UserDefaults = .standard ) { - self.init(keychain: keychain, persistsSecrets: persistsSecrets) + self.init( + keychain: keychain, + persistsSecrets: persistsSecrets, + permissionDefaults: permissionDefaults + ) let persistence = AIPersistence(context: modelContext) self.persistence = persistence do { @@ -378,10 +389,11 @@ final class AIStore { mutatePersistedState { lastSelectedModel = ref } } - /// Records a composer permission choice for subsequent unset conversations. - /// It does not alter command or MCP execution policy. + /// Records a composer permission choice for subsequent unset conversations + /// and future app launches. It does not alter command or MCP execution policy. func select(_ permissionMode: ChatPermissionMode) { lastSelectedPermissionMode = permissionMode + permissionDefaults?.set(permissionMode.rawValue, forKey: SettingsKey.agentPermissionMode) } // MARK: - Provider persistence diff --git a/TermindTests/ChatComposerStateTests.swift b/TermindTests/ChatComposerStateTests.swift index 692cd0c9..2da8b251 100644 --- a/TermindTests/ChatComposerStateTests.swift +++ b/TermindTests/ChatComposerStateTests.swift @@ -101,6 +101,51 @@ struct ChatComposerStateTests { #expect(store.resolve(conversation: nil as ChatPermissionMode?) == .approveForMe) } + @Test + func permissionDefaultPersistsAcrossStoreInstancesIncludingFullAccess() throws { + let suiteName = "ChatPermissionMode-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = AIStore(persistsSecrets: false, permissionDefaults: defaults) + first.select(ChatPermissionMode.approveForMe) + + let second = AIStore(persistsSecrets: false, permissionDefaults: defaults) + #expect(second.lastSelectedPermissionMode == .approveForMe) + + second.select(ChatPermissionMode.fullAccess) + + let third = AIStore(persistsSecrets: false, permissionDefaults: defaults) + #expect(third.lastSelectedPermissionMode == .fullAccess) + + let explicit = ConversationModel() + explicit.permissionMode = .askForApproval + #expect(third.resolve(conversation: explicit.permissionMode) == .askForApproval) + } + + @Test + func missingPersistedPermissionDefaultsToAskForApproval() throws { + let suiteName = "ChatPermissionMode-Missing-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = AIStore(persistsSecrets: false, permissionDefaults: defaults) + + #expect(store.lastSelectedPermissionMode == .askForApproval) + } + + @Test + func invalidPersistedPermissionDefaultsToAskForApproval() throws { + let suiteName = "ChatPermissionMode-Invalid-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set("unknown-mode", forKey: SettingsKey.agentPermissionMode) + + let store = AIStore(persistsSecrets: false, permissionDefaults: defaults) + + #expect(store.lastSelectedPermissionMode == .askForApproval) + } + @Test func modelSelectionStillFeedsUnsetConversationsWithoutOverridingExplicitOnes() { let firstModel = AIModel(id: "first")