// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import AgentKit import Foundation import TermindApplication import TermindDomain import TermindPresentation import Testing @testable import TermindApplication @Suite @MainActor struct AssistantNotificationTests { @Test(arguments: [false, true]) func removingConversationOrStoppingCancelsDeliveryAndDetachesEvents(stop: Bool) async { let conversation = ConversationModel() let store = AssistantConversationStore(conversations: [conversation]) let sender = NotificationDeliveryRecorder() let coordinator = AssistantNotificationCoordinator( service: sender, preferences: { .init() }, isFocused: { _ in false }, content: AssistantNotificationContent.make) coordinator.observe(conversation) conversation.handle(.runFinished(.init(id: UUID(), state: .completed, startedAt: .now))) _ = await sender.nextRequest() if stop { coordinator.stop() } else { store.remove(conversation.id) } await sender.finishDelivery() #expect(sender.sent.isEmpty) let unread = conversation.unreadNotificationCount conversation.handle(.runFinished(.init(id: UUID(), state: .completed, startedAt: .now))) #expect(conversation.unreadNotificationCount == unread) #expect(conversation.notificationEvent == nil) } @Test func stoppingKeepsAlreadyDeliveredCompletionNotifications() async { let conversation = ConversationModel() let sender = NotificationDeliveryRecorder() let coordinator = AssistantNotificationCoordinator( service: sender, preferences: { .init() }, isFocused: { _ in false }, content: AssistantNotificationContent.make) coordinator.observe(conversation) conversation.handle(.runFinished(.init(id: UUID(), state: .completed, startedAt: .now))) _ = await sender.nextRequest() await sender.finishDelivery() coordinator.stop() #expect(sender.sent.count == 1) #expect(sender.removed.isEmpty) } @Test func manuallyChangingUnreadStateIsIdempotentAndDoesNotSendNotifications() async { let fixture = AssistantNotificationFixture() let conversation = fixture.conversation conversation.markNotificationsUnread() conversation.markNotificationsUnread() #expect(conversation.unreadNotificationCount == 1) conversation.markNotificationsRead() #expect(conversation.unreadNotificationCount == 0) await settle() #expect(fixture.sender.sent.isEmpty) #expect(ConversationModel().unreadNotificationCount == 0) } @Test func completedNotificationUsesTheFinalReplyFromThatRun() async { let fixture = AssistantNotificationFixture() fixture.conversation.customTitle = "Deploy staging" let old = AgentRunSummary(id: UUID(), state: .running, startedAt: .now) fixture.conversation.handle(.runStarted(old)) fixture.conversation.handle(.messageFinished(.init(role: .assistant, text: "Old reply"))) var run = AgentRunSummary(id: UUID(), state: .running, startedAt: .now) fixture.conversation.handle(.runStarted(run)) fixture.conversation.handle(.messageFinished(.init(role: .assistant, text: "Deployment is ready."))) run.state = .completed fixture.conversation.handle(.runFinished(run)) await settle() #expect(fixture.sender.sent.last?.title == "Deploy staging") #expect(fixture.sender.sent.last?.body == "Deployment is ready.") let emptyRun = AgentRunSummary(id: UUID(), state: .completed, startedAt: .now) fixture.conversation.handle(.runFinished(emptyRun)) await settle() #expect(fixture.sender.sent.last?.body != "Deployment is ready.") } @Test func unreadCountsAreIndependentOfSystemDeliveryAndResetWithTheModel() async { let fixture = AssistantNotificationFixture() fixture.settings.isEnabled = false let run = AgentRunSummary(id: UUID(), state: .completed, startedAt: .now) fixture.conversation.handle(.runFinished(run)) fixture.conversation.handle(.runFinished(run)) #expect(fixture.conversation.unreadNotificationCount == 1) let request = request() fixture.conversation.present(request) #expect(fixture.conversation.unreadNotificationCount == 2) fixture.conversation.cancelUserInput() #expect(fixture.conversation.unreadNotificationCount == 1) fixture.conversation.markNotificationsRead() #expect(fixture.conversation.unreadNotificationCount == 0) #expect(ConversationModel().unreadNotificationCount == 0) await settle() #expect(fixture.sender.sent.isEmpty) } @Test func globalAssistantSuppressionStillCountsUnseenConversationEvents() async { let conversation = ConversationModel() let sender = NotificationRecorder() let coordinator = AssistantNotificationCoordinator( service: sender, preferences: { .init() }, isFocused: { _ in true }, isVisible: { _ in false }, content: AssistantNotificationContent.make) coordinator.observe(conversation) conversation.handle(.runFinished(.init(id: UUID(), state: .completed, startedAt: .now))) await settle() #expect(sender.sent.isEmpty) #expect(conversation.unreadNotificationCount == 1) } @Test func appBadgeTracksOnlyLiveUnreadCountsAndStopsAtZero() async { let conversation = ConversationModel() let sender = NotificationRecorder() let badge = NotificationBadgeCoordinator(service: sender) { conversation.unreadNotificationCount } #expect(sender.badgeCounts == [0]) conversation.markNotificationUnread("first") conversation.markNotificationUnread("second") await settle() #expect(sender.badgeCounts.last == 2) conversation.markNotificationsRead() await settle() #expect(sender.badgeCounts.last == 0) badge.stop() conversation.markNotificationUnread("third") await settle() #expect(sender.badgeCounts.last == 0) } @Test func completedAndFailedRunsNotifyOnceWithoutReplacingOtherCallbacks() async { let fixture = AssistantNotificationFixture() var titleCalls = 0 var memoryCalls = 0 fixture.conversation.runDidFinish = { titleCalls += 1 } fixture.conversation.memoryRunFinished = { _, _, _, _ in memoryCalls += 1 } for state in [AgentRunState.completed, .failed, .cancelled, .interrupted] { let run = AgentRunSummary(id: UUID(), state: state, startedAt: .now, finishedAt: .now) fixture.conversation.handle(.runState(state)) fixture.conversation.handle(.runFinished(run)) fixture.conversation.handle(.runFinished(run)) } await settle() #expect(fixture.sender.sent.count == 2) #expect(titleCalls == 4 && memoryCalls == 8) #expect(fixture.sender.sent.allSatisfy { $0.target == .assistant(fixture.conversation.id) }) } @Test func preparationFailuresNotifyAndRestorationDoesNotReplay() async { let fixture = AssistantNotificationFixture() let snapshot = AgentRunSnapshot( conversationID: fixture.conversation.id, state: .failed, permissionMode: .askForApproval, failure: "Connection failed: password=secret-value") fixture.conversation.recordFailedRun(snapshot) fixture.conversation.recordFailedRun(snapshot) await settle() #expect(fixture.sender.sent.count == 1) #expect(fixture.sender.sent[0].body.contains("Connection failed")) #expect(!fixture.sender.sent[0].body.contains("secret-value")) let restored = ConversationModel( runState: .completed, runs: [.init(id: UUID(), state: .completed, startedAt: .now)]) fixture.coordinator.observe(restored) await settle() #expect(fixture.sender.sent.count == 1) } @Test(arguments: NotificationPreferences.Mode.allCases, [false, true]) func assistantModeUsesTheConversationFocus(_ mode: NotificationPreferences.Mode, focused: Bool) async { let fixture = AssistantNotificationFixture() fixture.settings.assistantMode = mode fixture.focused = focused let run = AgentRunSummary(id: UUID(), state: .completed, startedAt: .now) fixture.conversation.handle(.runFinished(run)) await settle() #expect(fixture.sender.sent.count == (mode.allows(isFocused: focused) ? 1 : 0)) fixture.settings.assistantMode = .always fixture.focused = false fixture.conversation.handle(.runFinished(run)) await settle() #expect(fixture.sender.sent.count == (mode.allows(isFocused: focused) ? 1 : 0)) } @Test func queueHeadsNotifyOnceAndResolvedOrStoppedRequestsAreRemoved() async throws { let fixture = AssistantNotificationFixture() let first = request() let second = request(secret: true, date: first.createdAt.addingTimeInterval(1)) fixture.conversation.present(first) fixture.conversation.present(second) await settle() #expect(fixture.sender.sent.count == 1) fixture.conversation.cancelUserInput() await settle() #expect(fixture.sender.sent.count == 2) #expect(fixture.sender.removed.contains(fixture.sender.sent[0].id)) #expect(fixture.conversation.notificationInteraction?.kind == .secret) fixture.conversation.handle(.runState(.cancelled)) #expect(fixture.sender.removed.contains(fixture.sender.sent[1].id)) fixture.conversation.cancelUserInput() } @Test func pendingDeliveryRechecksRequestAndSettingsAfterSuspension() async { let fixture = AssistantNotificationFixture() fixture.sender.hold = true fixture.conversation.present(request()) await settle() #expect(fixture.sender.waiter != nil) fixture.conversation.cancelUserInput() fixture.sender.resume() await settle() #expect(fixture.sender.sent.isEmpty) fixture.conversation.present(request()) await settle() fixture.settings.isEnabled = false fixture.sender.resume() await settle() #expect(fixture.sender.sent.isEmpty) fixture.conversation.cancelUserInput() } @Test func timeoutRemovesThePromptWithoutResettingItsDeadline() async { let fixture = AssistantNotificationFixture() var wake: CheckedContinuation? fixture.conversation.questionClockSleep = { _ in await withCheckedContinuation { wake = $0 } } fixture.conversation.present(request()) await settle() #expect(fixture.sender.sent.count == 1) wake?.resume() await settle() #expect(fixture.conversation.pendingUserInput == nil) #expect(fixture.sender.removed.contains(fixture.sender.sent[0].id)) } @Test(arguments: [false, true]) func authenticationAndPassphraseUseOnlyTheirCategory(passphrase: Bool) async { let fixture = AssistantNotificationFixture() let request = AgentSSHAuthenticationRequest( invocationID: UUID(), hostID: UUID(), displayName: "private-host", address: "secret.example", mode: passphrase ? .passphrase(keyID: UUID()) : .identity, username: "private-user") let task = Task { await fixture.conversation.requestSSHAuthentication(request) } await settle() #expect(fixture.conversation.notificationInteraction?.kind == (passphrase ? .passphrase : .authentication)) #expect(fixture.sender.sent.count == 1) #expect(!fixture.sender.sent[0].body.contains("private")) fixture.conversation.respondToSSHAuthentication(.cancelled) _ = await task.value #expect(fixture.sender.removed.contains(fixture.sender.sent[0].id)) } @Test func emptyKeyboardChallengeCompletesWithoutNotification() async { let fixture = AssistantNotificationFixture() let challenge = SSHKeyboardInteractiveChallenge( connectionID: UUID(), hostID: UUID(), hostname: "example", port: 22, username: "user", round: 0, name: "", instruction: "", prompts: []) let request = AgentSSHAuthenticationRequest( invocationID: UUID(), hostID: UUID(), displayName: "example", address: "example", mode: .keyboardInteractive(challenge), username: "user") _ = await fixture.conversation.requestSSHAuthentication(request) await settle() #expect(fixture.sender.sent.isEmpty) } @Test func hostKeyConfirmationAndApprovalFollowTheVisibleInteractionPriority() async { let fixture = AssistantNotificationFixture() let question = request() fixture.conversation.present(question) await settle() let invocation = AgentToolInvocation( runID: UUID(), call: AgentToolCall(id: "call", name: "review", arguments: .object([:]))) let approval = AgentApprovalRequest( invocation: invocation, descriptor: .init( name: "review", summary: "private", inputSchema: .object([:]), target: .host, approvalPolicy: .ask), userIntent: "private-intent") let approvalTask = Task { await fixture.conversation.requestApproval(approval) } let hostKey = AgentSSHHostKeyRequest( createdAt: question.createdAt.addingTimeInterval(1), invocationID: UUID(), hostID: UUID(), displayName: "private-host", address: "private.example", prompt: .init( observation: .init( hostname: "private.example", port: 22, algorithm: .ed25519, fingerprint: "private-fingerprint"), kind: .firstUse), allowsPersistentTrust: true) let hostTask = Task { await fixture.conversation.requestSSHHostKeyConfirmation(hostKey) } await settle() #expect(fixture.sender.sent.count == 1) fixture.conversation.cancelUserInput() await settle() #expect(fixture.conversation.notificationInteraction?.kind == .hostKey) #expect(fixture.sender.sent.count == 2) fixture.conversation.respondToSSHHostKey(.deny) _ = await hostTask.value await settle() #expect(fixture.conversation.notificationInteraction?.kind == .approval) #expect(fixture.sender.sent.count == 3) fixture.conversation.respondToApproval(approval.id, allow: false) _ = await approvalTask.value #expect(fixture.sender.removed.count == 3) #expect(fixture.sender.sent.allSatisfy { !$0.body.contains("private") }) } @Test func questionPagingDoesNotNotifyAgainOrMoveTheDeadline() async { let fixture = AssistantNotificationFixture() let request = AgentUserInputRequest(questions: [ .init(id: "first", prompt: "First", kind: .text), .init(id: "second", prompt: "Second", kind: .text), ]) fixture.conversation.present(request) let deadline = fixture.conversation.userInputDeadline await settle() fixture.conversation.skipUserInputQuestion() await settle() #expect(fixture.sender.sent.count == 1) #expect(fixture.conversation.userInputDeadline == deadline) fixture.conversation.cancelUserInput() } @Test func notificationTitlesMatchTheConversationTitleAndInteractionBodiesExcludePrompts() { let conversation = ConversationModel(messages: [.init(role: .user, text: "password=secret")]) let text = AssistantNotificationContent.make(conversation, kind: .questions) #expect(text.title == conversation.derivedTitle) #expect(!text.body.contains("password")) conversation.customTitle = "Build\nstatus" #expect(AssistantNotificationContent.make(conversation, kind: .completed).title == "Build status") } private func request(secret: Bool = false, date: Date = .now) -> AgentUserInputRequest { .init(createdAt: date, questions: [.init(id: "q", prompt: "private-prompt", kind: secret ? .secret : .text)]) } private func settle() async { for _ in 0..<30 { await Task.yield() } } } @MainActor private final class AssistantNotificationFixture { let conversation = ConversationModel() let sender = NotificationRecorder() var settings = NotificationPreferences() var focused = false lazy var coordinator = AssistantNotificationCoordinator( service: sender, preferences: { [unowned self] in settings }, isFocused: { [unowned self] _ in focused }, content: AssistantNotificationContent.make) init() { settings.isEnabled = true coordinator.observe(conversation) } } @MainActor private final class NotificationRecorder: NotificationSending { var sent: [AppNotification] = [] var removed: [String] = [] var badgeCounts: [Int] = [] func setBadgeCount(_ count: Int) { badgeCounts.append(count) } var hold = false var waiter: CheckedContinuation? func authorization(request: Bool) async -> NotificationAuthorization { .allowed } func send(_ notification: AppNotification, ifCurrent: @escaping @MainActor () -> Bool) async { if hold { await withCheckedContinuation { waiter = $0 } } guard !Task.isCancelled, ifCurrent() else { return } sent.append(notification) } func remove(_ identifiers: [String]) { removed.append(contentsOf: identifiers) } func resume() { waiter?.resume() waiter = nil } }