Files
Termind/Frameworks/TermindApplication/Agent/AssistantNotifications.swift
T
Kaede Akino 4d6e4481c0 feat(mcp): add OAuth and 2025-03-26 compatibility
Integrate AgentKit 0.18.1 with native authorization on macOS and iOS, device-local OAuth credentials, V43 configuration persistence, and Assistant authorization prompts.

Add HTTPS callback metadata, keep native callbacks safe across actor boundaries, and distinguish authorization setup failures from cancellation. Extend the external MCP server to negotiate 2025-03-26.
2026-09-28 12:36:32 +08:00

154 lines
6.8 KiB
Swift

import Foundation
import TermindDomain
public nonisolated enum AssistantNotificationKind: Hashable, Sendable {
case completed, failed, questions, secret, approval, authentication, passphrase, hostKey, mcpAuthorization
}
public nonisolated struct AssistantNotificationInteraction: Equatable, Sendable {
public let id: UUID
public let kind: AssistantNotificationKind
public init(id: UUID, kind: AssistantNotificationKind) {
self.id = id
self.kind = kind
}
}
public nonisolated enum AssistantNotificationEvent: Sendable {
case finished(runID: UUID, kind: AssistantNotificationKind)
case interactionChanged(AssistantNotificationInteraction?)
case removed
}
extension ConversationModel {
/// Mirrors the transcript's interaction priority, without exposing prompt or credential data.
public var notificationInteraction: AssistantNotificationInteraction? {
guard !runState.isTerminal, !isArchived else { return nil }
if let interaction = pendingUserInteraction {
switch interaction {
case .mcpAuthorization(let prompt):
return .init(id: prompt.id, kind: .mcpAuthorization)
case .questions(let request):
let secret = request.questions.contains { if case .secret = $0.kind { true } else { false } }
return .init(id: request.id, kind: secret ? .secret : .questions)
case .sshAuthentication(let request):
if request.keyboardInteractive?.prompts.isEmpty == true { return nil }
return .init(id: request.id, kind: request.requiresPassphrase ? .passphrase : .authentication)
case .sshHostKey(let request):
return .init(id: request.id, kind: .hostKey)
}
}
return pendingApproval.map { .init(id: $0.id, kind: .approval) }
}
}
/// App-owned, independent of view mounting, title generation and Memory callbacks.
@MainActor
public final class AssistantNotificationCoordinator {
private let service: any NotificationSending
private let preferences: @MainActor () -> NotificationPreferences
private let isFocused: @MainActor (UUID) -> Bool
private let isVisible: @MainActor (UUID) -> Bool
private let content:
@MainActor (ConversationModel, AssistantNotificationKind, UUID?) -> (title: String, body: String)
private struct Observation {
weak var conversation: ConversationModel?
var seen = Set<String>()
}
private var observations: [UUID: Observation] = [:]
private var stopped = false
private var interactionIDs: [UUID: String] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
public init(
service: any NotificationSending,
preferences: @escaping @MainActor () -> NotificationPreferences,
isFocused: @escaping @MainActor (UUID) -> Bool,
isVisible: (@MainActor (UUID) -> Bool)? = nil,
content:
@escaping @MainActor (ConversationModel, AssistantNotificationKind, UUID?) -> (title: String, body: String)
) {
self.service = service
self.preferences = preferences
self.isFocused = isFocused
self.isVisible = isVisible ?? isFocused
self.content = content
}
public func observe(_ conversation: ConversationModel) {
guard !stopped else { return }
for id in observations.keys.filter({ observations[$0]?.conversation == nil }) { unobserve(id) }
if observations[conversation.id]?.conversation !== conversation {
unobserve(conversation.id)
observations[conversation.id] = Observation(conversation: conversation)
}
conversation.notificationEvent = { [weak self, weak conversation] event in
guard let self, let conversation else { return }
receive(event, from: conversation)
}
}
public func unobserve(_ id: UUID) {
guard let observation = observations.removeValue(forKey: id) else { return }
observation.conversation?.notificationEvent = nil
for identifier in observation.seen { tasks.removeValue(forKey: identifier)?.cancel() }
if let interaction = interactionIDs.removeValue(forKey: id) { service.remove([interaction]) }
}
public func stop() {
stopped = true
for id in Array(observations.keys) { unobserve(id) }
}
private func receive(_ event: AssistantNotificationEvent, from conversation: ConversationModel) {
guard !stopped else { return }
let identifier: String
let kind: AssistantNotificationKind
let requestID: UUID?
let completedRunID: UUID?
switch event {
case .removed:
unobserve(conversation.id)
return
case .finished(let runID, let outcome):
guard outcome == .completed || outcome == .failed else { return }
identifier = "assistant.\(conversation.id).run.\(runID)"
kind = outcome
requestID = nil
completedRunID = runID
case .interactionChanged(let interaction):
let nextID = interaction.map { "assistant.\(conversation.id).interaction.\($0.id)" }
if let previous = interactionIDs[conversation.id], previous != nextID {
tasks.removeValue(forKey: previous)?.cancel()
service.remove([previous])
conversation.removeUnreadNotification(previous)
}
interactionIDs[conversation.id] = nextID
guard let interaction, let nextID else { return }
identifier = nextID
kind = interaction.kind
requestID = interaction.id
completedRunID = nil
}
// Suppressed events are also consumed: changing focus/settings never replays them.
guard observations[conversation.id]?.seen.insert(identifier).inserted == true else { return }
if !isVisible(conversation.id) { conversation.markNotificationUnread(identifier) }
guard preferences().allowsAssistant(isFocused: isFocused(conversation.id)) else { return }
let text = content(conversation, kind, completedRunID)
let notification = AppNotification(
id: identifier, target: .assistant(conversation.id), title: text.title, body: text.body)
tasks[identifier] = Task { [weak self, weak conversation] in
guard let self else { return }
defer { tasks[identifier] = nil }
await service.send(notification) { [weak self, weak conversation] in
guard let self, !stopped, let conversation,
observations[conversation.id]?.conversation === conversation,
self.preferences().allowsAssistant(isFocused: self.isFocused(conversation.id))
else { return false }
return requestID == nil || conversation.notificationInteraction?.id == requestID
}
}
}
}