From 709a620902762b4f383f504d8c636bba4ecbcca1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:01:47 -0700 Subject: [PATCH 01/14] test: cover auto-allowed feed notifications --- Sources/Feed/FeedCoordinator.swift | 18 ++++++ cmuxTests/FeedCoordinatorTests.swift | 91 ++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 9d02e733bde5..59a6b5fd7224 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -118,6 +118,9 @@ final class FeedCoordinator: @unchecked Sendable { if let ppid = event.ppid, ppid > 0 { FeedCoordinator.shared.armPidWatcher(ppid: ppid) } + #if DEBUG + FeedCoordinatorTestHooks.afterBlockingEventIngested?(event, requestId) + #endif } } @@ -231,6 +234,14 @@ private final class SnapshotSlot: @unchecked Sendable { var value: [WorkstreamItem] = [] } +#if DEBUG +@MainActor +enum FeedCoordinatorTestHooks { + static var afterBlockingEventIngested: (@Sendable (WorkstreamEvent, String) -> Void)? + static var notificationPostObserver: (@Sendable (WorkstreamEvent, String) -> Void)? +} +#endif + // MARK: - Socket-layer helpers extension FeedCoordinator { @@ -389,6 +400,13 @@ extension Notification.Name { /// focused so the user isn't double-notified. private func postFeedNotification(event: WorkstreamEvent, requestId: String) { DispatchQueue.main.async { + #if DEBUG + if let observer = FeedCoordinatorTestHooks.notificationPostObserver { + observer(event, requestId) + return + } + #endif + // Don't pester users while the app is already up front. if NSApp.isActive { return diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 80d5112e96ec..cf82db5ceece 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -64,8 +64,99 @@ final class FeedCoordinatorTests: XCTestCase { return } } + + func testBlockingIngestSkipsNotificationWhenPermissionResolvesBeforeDisplay() async { + let requestId = "auto-allow-request" + let notifications = NotificationRequestRecorder() + + await MainActor.run { + let store = WorkstreamStore(ringCapacity: 10) + FeedCoordinator.shared.install(store: store) + FeedCoordinatorTestHooks.afterBlockingEventIngested = { _, ingestedRequestId in + guard ingestedRequestId == requestId else { return } + FeedCoordinator.shared.deliverReply( + requestId: ingestedRequestId, + decision: .permission(.once) + ) + } + FeedCoordinatorTestHooks.notificationPostObserver = { _, postedRequestId in + notifications.record(postedRequestId) + } + } + + let event = WorkstreamEvent( + sessionId: "claude-auto-allow-test", + hookEventName: .permissionRequest, + source: "claude", + cwd: "/tmp", + toolName: "Bash", + toolInputJSON: #"{"command":"true"}"#, + requestId: requestId + ) + + let done = DispatchSemaphore(value: 0) + let resultBox = IngestResultBox() + + DispatchQueue.global(qos: .userInitiated).async { + resultBox.value = FeedCoordinator.shared.ingestBlocking( + event: event, + waitTimeout: 1 + ) + done.signal() + } + + XCTAssertEqual(done.wait(timeout: .now() + 2), .success) + + let mainQueueDrained = DispatchSemaphore(value: 0) + DispatchQueue.main.async { + mainQueueDrained.signal() + } + XCTAssertEqual(mainQueueDrained.wait(timeout: .now() + 2), .success) + + if case .resolved(_, .permission(.once)) = resultBox.value { + // ok + } else { + XCTFail("expected auto-allowed permission request to resolve") + } + + let status = await MainActor.run { + FeedCoordinator.shared.store.items.first?.status + } + if case .resolved(.permission(.once), _) = status { + // ok + } else { + XCTFail("auto-allowed hook item should be resolved") + } + + XCTAssertTrue( + notifications.requestIds.isEmpty, + "auto-allowed permission requests should not post native notifications" + ) + + await MainActor.run { + FeedCoordinatorTestHooks.afterBlockingEventIngested = nil + FeedCoordinatorTestHooks.notificationPostObserver = nil + } + } } private final class IngestResultBox: @unchecked Sendable { var value: FeedCoordinator.IngestBlockingResult? } + +private final class NotificationRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedRequestIds: [String] = [] + + var requestIds: [String] { + lock.lock() + defer { lock.unlock() } + return recordedRequestIds + } + + func record(_ requestId: String) { + lock.lock() + recordedRequestIds.append(requestId) + lock.unlock() + } +} From 135dfb6b72689df2cef23dbdd6ae1cd742af266f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:03:01 -0700 Subject: [PATCH 02/14] fix: skip notifications after feed auto-allow --- Sources/Feed/FeedCoordinator.swift | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 59a6b5fd7224..559a5f39ea03 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -173,6 +173,15 @@ final class FeedCoordinator: @unchecked Sendable { } else { DispatchQueue.main.async(execute: resolve) } + + cancelFeedNotification(requestId: requestId) + } + + fileprivate func isAwaitingDecision(requestId: String) -> Bool { + waiterLock.lock() + defer { waiterLock.unlock() } + guard let waiter = waiters[requestId] else { return false } + return waiter.decision == nil } private static func findItemId( @@ -400,6 +409,10 @@ extension Notification.Name { /// focused so the user isn't double-notified. private func postFeedNotification(event: WorkstreamEvent, requestId: String) { DispatchQueue.main.async { + guard FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) else { + return + } + #if DEBUG if let observer = FeedCoordinatorTestHooks.notificationPostObserver { observer(event, requestId) @@ -471,13 +484,18 @@ private func postFeedNotification(event: WorkstreamEvent, requestId: String) { ) let center = UNUserNotificationCenter.current() + let requestIsStillPending: @Sendable () -> Bool = { + FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) + } center.getNotificationSettings { settings in + guard requestIsStillPending() else { return } switch settings.authorizationStatus { case .authorized, .provisional: center.add(request) { _ in /* best effort */ } case .notDetermined: center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - if granted { center.add(request) { _ in } } + guard granted, requestIsStillPending() else { return } + center.add(request) { _ in } } default: break @@ -486,6 +504,13 @@ private func postFeedNotification(event: WorkstreamEvent, requestId: String) { } } +private func cancelFeedNotification(requestId: String) { + let identifier = "feed.\(requestId)" + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + center.removeDeliveredNotifications(withIdentifiers: [identifier]) +} + /// JSON-shape helpers used by the V2 `feed.*` socket handlers. enum FeedSocketEncoding { private static let primaryTextLimit = 8_000 From d194d33326146e271a9e6e29e042d9784792539d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:33:38 -0700 Subject: [PATCH 03/14] fix: address feed notification review feedback --- Sources/Feed/FeedCoordinator.swift | 12 ++++++++++-- cmuxTests/FeedCoordinatorTests.swift | 18 +++++++----------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 559a5f39ea03..2d9e5692a693 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -141,9 +141,11 @@ final class FeedCoordinator: @unchecked Sendable { if let decision = w?.decision { return .resolved(itemId: itemIdSlot.value, decision: decision) } + cancelFeedNotification(requestId: requestId) expireTimedOutItem(itemIdSlot.value) return .timedOut(itemId: itemIdSlot.value) case .timedOut: + cancelFeedNotification(requestId: requestId) expireTimedOutItem(itemIdSlot.value) return .timedOut(itemId: itemIdSlot.value) } @@ -414,8 +416,14 @@ private func postFeedNotification(event: WorkstreamEvent, requestId: String) { } #if DEBUG - if let observer = FeedCoordinatorTestHooks.notificationPostObserver { - observer(event, requestId) + let handledByTestObserver = MainActor.assumeIsolated { + if let observer = FeedCoordinatorTestHooks.notificationPostObserver { + observer(event, requestId) + return true + } + return false + } + if handledByTestObserver { return } #endif diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index cf82db5ceece..eab1480ffaa5 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -38,7 +38,7 @@ final class FeedCoordinatorTests: XCTestCase { requestId: "timeout-request" ) - let done = DispatchSemaphore(value: 0) + let done = expectation(description: "blocking ingest timed out") let resultBox = IngestResultBox() DispatchQueue.global(qos: .userInitiated).async { @@ -46,10 +46,10 @@ final class FeedCoordinatorTests: XCTestCase { event: event, waitTimeout: 0.05 ) - done.signal() + done.fulfill() } - XCTAssertEqual(done.wait(timeout: .now() + 2), .success) + await fulfillment(of: [done], timeout: 2) guard case .timedOut = resultBox.value else { XCTFail("expected feed.push to time out") @@ -94,7 +94,7 @@ final class FeedCoordinatorTests: XCTestCase { requestId: requestId ) - let done = DispatchSemaphore(value: 0) + let done = expectation(description: "blocking ingest resolved") let resultBox = IngestResultBox() DispatchQueue.global(qos: .userInitiated).async { @@ -102,16 +102,12 @@ final class FeedCoordinatorTests: XCTestCase { event: event, waitTimeout: 1 ) - done.signal() + done.fulfill() } - XCTAssertEqual(done.wait(timeout: .now() + 2), .success) + await fulfillment(of: [done], timeout: 2) - let mainQueueDrained = DispatchSemaphore(value: 0) - DispatchQueue.main.async { - mainQueueDrained.signal() - } - XCTAssertEqual(mainQueueDrained.wait(timeout: .now() + 2), .success) + await MainActor.run {} if case .resolved(_, .permission(.once)) = resultBox.value { // ok From 948d7708e8d5cb4ec3db5dd7da6aaa48de605879 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:40:03 -0700 Subject: [PATCH 04/14] fix: close feed notification enqueue race --- Sources/Feed/FeedCoordinator.swift | 32 +++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 2d9e5692a693..c42810dc1c19 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -492,18 +492,23 @@ private func postFeedNotification(event: WorkstreamEvent, requestId: String) { ) let center = UNUserNotificationCenter.current() - let requestIsStillPending: @Sendable () -> Bool = { - FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) - } center.getNotificationSettings { settings in - guard requestIsStillPending() else { return } + guard FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) else { return } switch settings.authorizationStatus { case .authorized, .provisional: - center.add(request) { _ in /* best effort */ } + addFeedNotificationIfAwaitingDecision( + center: center, + request: request, + requestId: requestId + ) case .notDetermined: center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - guard granted, requestIsStillPending() else { return } - center.add(request) { _ in } + guard granted else { return } + addFeedNotificationIfAwaitingDecision( + center: center, + request: request, + requestId: requestId + ) } default: break @@ -512,6 +517,19 @@ private func postFeedNotification(event: WorkstreamEvent, requestId: String) { } } +private func addFeedNotificationIfAwaitingDecision( + center: UNUserNotificationCenter, + request: UNNotificationRequest, + requestId: String +) { + guard FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) else { return } + center.add(request) { _ in + if !FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) { + cancelFeedNotification(requestId: requestId) + } + } +} + private func cancelFeedNotification(requestId: String) { let identifier = "feed.\(requestId)" let center = UNUserNotificationCenter.current() From 0a85071f6f255668b0e070cbec0973aa8e0d2e52 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:14:42 -0700 Subject: [PATCH 05/14] refactor: centralize feed notification gating --- Sources/Feed/FeedCoordinator.swift | 231 +++++++++++++++-------------- 1 file changed, 118 insertions(+), 113 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index c42810dc1c19..2c1d165f269f 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -127,7 +127,7 @@ final class FeedCoordinator: @unchecked Sendable { // If this is a blocking actionable event and the app window isn't // focused, post a native notification banner with inline action // buttons so the user can respond without switching windows. - postFeedNotification(event: event, requestId: requestId) + postNotificationIfStillAwaiting(event: event, requestId: requestId) let deadline: DispatchTime = .now() + waitTimeout let waitResult = semaphore.wait(timeout: deadline) @@ -141,11 +141,11 @@ final class FeedCoordinator: @unchecked Sendable { if let decision = w?.decision { return .resolved(itemId: itemIdSlot.value, decision: decision) } - cancelFeedNotification(requestId: requestId) + cancelNotification(requestId: requestId) expireTimedOutItem(itemIdSlot.value) return .timedOut(itemId: itemIdSlot.value) case .timedOut: - cancelFeedNotification(requestId: requestId) + cancelNotification(requestId: requestId) expireTimedOutItem(itemIdSlot.value) return .timedOut(itemId: itemIdSlot.value) } @@ -176,7 +176,7 @@ final class FeedCoordinator: @unchecked Sendable { DispatchQueue.main.async(execute: resolve) } - cancelFeedNotification(requestId: requestId) + cancelNotification(requestId: requestId) } fileprivate func isAwaitingDecision(requestId: String) -> Bool { @@ -406,135 +406,140 @@ extension Notification.Name { // MARK: - Native notification banner -/// Posts a UNUserNotificationCenter banner with inline action buttons -/// for the given Feed event. Skips if the app window is already key/ -/// focused so the user isn't double-notified. -private func postFeedNotification(event: WorkstreamEvent, requestId: String) { - DispatchQueue.main.async { - guard FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) else { - return - } +private extension FeedCoordinator { + /// Posts a UNUserNotificationCenter banner with inline action buttons + /// for the given Feed event. Notification eligibility is derived only + /// from the waiter table so resolved/timed-out requests cannot enqueue + /// stale banners while the main queue or notification center catches up. + func postNotificationIfStillAwaiting(event: WorkstreamEvent, requestId: String) { + DispatchQueue.main.async { [weak self] in + guard let self, self.isAwaitingDecision(requestId: requestId) else { + return + } - #if DEBUG - let handledByTestObserver = MainActor.assumeIsolated { - if let observer = FeedCoordinatorTestHooks.notificationPostObserver { - observer(event, requestId) - return true + #if DEBUG + let handledByTestObserver = MainActor.assumeIsolated { + if let observer = FeedCoordinatorTestHooks.notificationPostObserver { + observer(event, requestId) + return true + } + return false } - return false - } - if handledByTestObserver { - return - } - #endif + if handledByTestObserver { + return + } + #endif - // Don't pester users while the app is already up front. - if NSApp.isActive { - return - } + // Don't pester users while the app is already up front. + if NSApp.isActive { + return + } - let categoryId: String - let title: String - let body: String - switch event.hookEventName { - case .permissionRequest: - categoryId = "CMUXFeedPermission" - title = String( - localized: "feed.notification.permission.title", - defaultValue: "\(event.source.capitalized) permission" - ) - body = event.toolName.map { - String( - localized: "feed.notification.permission.body", - defaultValue: "\($0) needs approval" + let categoryId: String + let title: String + let body: String + switch event.hookEventName { + case .permissionRequest: + categoryId = "CMUXFeedPermission" + title = String( + localized: "feed.notification.permission.title", + defaultValue: "\(event.source.capitalized) permission" ) - } ?? String( - localized: "feed.notification.decisionNeeded", - defaultValue: "Decision needed" - ) - case .exitPlanMode: - categoryId = "CMUXFeedExitPlan" - title = String( - localized: "feed.notification.exitPlan.title", - defaultValue: "\(event.source.capitalized) plan ready" - ) - body = String( - localized: "feed.notification.exitPlan.body", - defaultValue: "Review and approve the plan" - ) - case .askUserQuestion: - categoryId = "CMUXFeedQuestion" - title = String( - localized: "feed.notification.question.title", - defaultValue: "\(event.source.capitalized) question" - ) - body = String( - localized: "feed.notification.question.body", - defaultValue: "Agent is asking a question" - ) - default: - return - } + body = event.toolName.map { + String( + localized: "feed.notification.permission.body", + defaultValue: "\($0) needs approval" + ) + } ?? String( + localized: "feed.notification.decisionNeeded", + defaultValue: "Decision needed" + ) + case .exitPlanMode: + categoryId = "CMUXFeedExitPlan" + title = String( + localized: "feed.notification.exitPlan.title", + defaultValue: "\(event.source.capitalized) plan ready" + ) + body = String( + localized: "feed.notification.exitPlan.body", + defaultValue: "Review and approve the plan" + ) + case .askUserQuestion: + categoryId = "CMUXFeedQuestion" + title = String( + localized: "feed.notification.question.title", + defaultValue: "\(event.source.capitalized) question" + ) + body = String( + localized: "feed.notification.question.body", + defaultValue: "Agent is asking a question" + ) + default: + return + } - let content = UNMutableNotificationContent() - content.title = title - content.body = body - content.categoryIdentifier = categoryId - content.userInfo = [ - "requestId": requestId, - "workstreamId": event.sessionId, - ] + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.categoryIdentifier = categoryId + content.userInfo = [ + "requestId": requestId, + "workstreamId": event.sessionId, + ] - let request = UNNotificationRequest( - identifier: "feed.\(requestId)", - content: content, - trigger: nil - ) + let request = UNNotificationRequest( + identifier: "feed.\(requestId)", + content: content, + trigger: nil + ) - let center = UNUserNotificationCenter.current() - center.getNotificationSettings { settings in - guard FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) else { return } - switch settings.authorizationStatus { - case .authorized, .provisional: - addFeedNotificationIfAwaitingDecision( - center: center, - request: request, - requestId: requestId - ) - case .notDetermined: - center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - guard granted else { return } - addFeedNotificationIfAwaitingDecision( + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { [weak self] settings in + guard let self, self.isAwaitingDecision(requestId: requestId) else { return } + switch settings.authorizationStatus { + case .authorized, .provisional: + self.addNotificationIfStillAwaiting( center: center, request: request, requestId: requestId ) + case .notDetermined: + center.requestAuthorization(options: [.alert, .sound]) { + [weak self] granted, _ in + guard let self, granted else { return } + self.addNotificationIfStillAwaiting( + center: center, + request: request, + requestId: requestId + ) + } + default: + break } - default: - break } } } -} -private func addFeedNotificationIfAwaitingDecision( - center: UNUserNotificationCenter, - request: UNNotificationRequest, - requestId: String -) { - guard FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) else { return } - center.add(request) { _ in - if !FeedCoordinator.shared.isAwaitingDecision(requestId: requestId) { - cancelFeedNotification(requestId: requestId) + func addNotificationIfStillAwaiting( + center: UNUserNotificationCenter, + request: UNNotificationRequest, + requestId: String + ) { + guard isAwaitingDecision(requestId: requestId) else { return } + center.add(request) { [weak self] _ in + guard let self else { return } + if !self.isAwaitingDecision(requestId: requestId) { + self.cancelNotification(requestId: requestId) + } } } -} -private func cancelFeedNotification(requestId: String) { - let identifier = "feed.\(requestId)" - let center = UNUserNotificationCenter.current() - center.removePendingNotificationRequests(withIdentifiers: [identifier]) - center.removeDeliveredNotifications(withIdentifiers: [identifier]) + func cancelNotification(requestId: String) { + let identifier = "feed.\(requestId)" + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + center.removeDeliveredNotifications(withIdentifiers: [identifier]) + } } /// JSON-shape helpers used by the V2 `feed.*` socket handlers. From 828483c8bc692ff952ac3772155c4ced7fdb86f5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:12:09 -0700 Subject: [PATCH 06/14] test: reset feed notification hooks in teardown --- cmuxTests/FeedCoordinatorTests.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index eab1480ffaa5..14e5bc59b2d4 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -69,6 +69,10 @@ final class FeedCoordinatorTests: XCTestCase { let requestId = "auto-allow-request" let notifications = NotificationRequestRecorder() + addTeardownBlock { + Self.resetFeedCoordinatorTestHooks() + } + await MainActor.run { let store = WorkstreamStore(ringCapacity: 10) FeedCoordinator.shared.install(store: store) @@ -128,10 +132,19 @@ final class FeedCoordinatorTests: XCTestCase { notifications.requestIds.isEmpty, "auto-allowed permission requests should not post native notifications" ) + } - await MainActor.run { - FeedCoordinatorTestHooks.afterBlockingEventIngested = nil - FeedCoordinatorTestHooks.notificationPostObserver = nil + private static func resetFeedCoordinatorTestHooks() { + let reset: @Sendable () -> Void = { + MainActor.assumeIsolated { + FeedCoordinatorTestHooks.afterBlockingEventIngested = nil + FeedCoordinatorTestHooks.notificationPostObserver = nil + } + } + if Thread.isMainThread { + reset() + } else { + DispatchQueue.main.sync(execute: reset) } } } From 00a95cf63504bb9d9030a4060175ab9a1a24afff Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:06:35 -0700 Subject: [PATCH 07/14] fix: cancel feed notifications off main --- Sources/Feed/FeedCoordinator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 2c1d165f269f..b4e28100d6d3 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -537,8 +537,8 @@ private extension FeedCoordinator { func cancelNotification(requestId: String) { let identifier = "feed.\(requestId)" let center = UNUserNotificationCenter.current() - center.removePendingNotificationRequests(withIdentifiers: [identifier]) - center.removeDeliveredNotifications(withIdentifiers: [identifier]) + center.removePendingNotificationRequestsOffMain(withIdentifiers: [identifier]) + center.removeDeliveredNotificationsOffMain(withIdentifiers: [identifier]) } } From 719a5c54231435936379a74b58b78dde6b172d03 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:16:27 -0700 Subject: [PATCH 08/14] fix: align feed notification test hook with app focus gate --- Sources/Feed/FeedCoordinator.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index b4e28100d6d3..0b102a06ee0f 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -417,6 +417,11 @@ private extension FeedCoordinator { return } + // Don't pester users while the app is already up front. + if NSApp.isActive { + return + } + #if DEBUG let handledByTestObserver = MainActor.assumeIsolated { if let observer = FeedCoordinatorTestHooks.notificationPostObserver { @@ -430,11 +435,6 @@ private extension FeedCoordinator { } #endif - // Don't pester users while the app is already up front. - if NSApp.isActive { - return - } - let categoryId: String let title: String let body: String From cec39bd8b6ae9a64c5a3aa7833db743e27fb4e2a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 23:02:58 -0700 Subject: [PATCH 09/14] fix: update workspace transfer test fixture --- cmuxTests/WorkspaceUnitTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index fa5ab5c787b2..1edddb0f68cb 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2456,7 +2456,7 @@ final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { customTitle: nil, manuallyUnread: false, restorableAgent: nil, - restorableAgentAutoResumePending: false, + restorableAgentResumeState: nil, agentRuntime: nil, isRemoteTerminal: false, remoteRelayPort: nil, From dd5a680a60cc2b31876ea69a8eab1d7ff3f4822b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 14:10:16 -0700 Subject: [PATCH 10/14] chore: retrigger ci From 67a1302ba7643b47de2b0b7150f7463a82888146 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 14:42:36 -0700 Subject: [PATCH 11/14] fix: make feed desktop notification effect explicit --- Sources/Feed/FeedCoordinator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index fa809717495a..91920d1ae5a1 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -668,6 +668,7 @@ private func makeFeedNotificationPolicyContext( ?? workspace?.currentDirectory ?? FileManager.default.homeDirectoryForCurrentUser.path var effects = TerminalNotificationPolicyEffects() + effects.desktop = true effects.record = false effects.markUnread = false effects.reorderWorkspace = false From 33291c78abc86e4c48923ada75ef7c95cebe3560 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 15:14:47 -0700 Subject: [PATCH 12/14] fix: isolate feed notification callbacks --- Sources/Feed/FeedCoordinator.swift | 93 ++++++++++++++++------------ cmuxTests/FeedCoordinatorTests.swift | 2 + 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 91920d1ae5a1..b4ad0d6dce83 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -249,6 +249,7 @@ private final class SnapshotSlot: @unchecked Sendable { @MainActor enum FeedCoordinatorTestHooks { static var afterBlockingEventIngested: (@Sendable (WorkstreamEvent, String) -> Void)? + static var isAppActiveOverride: (@Sendable () -> Bool)? static var notificationPostObserver: (@Sendable (WorkstreamEvent, String) -> Void)? } #endif @@ -418,8 +419,14 @@ private extension FeedCoordinator { return } + #if DEBUG + let isAppActive = FeedCoordinatorTestHooks.isAppActiveOverride?() ?? NSApp.isActive + #else + let isAppActive = NSApp.isActive + #endif + // Don't pester users while the app is already up front. - if NSApp.isActive { + if isAppActive { return } @@ -580,39 +587,43 @@ private extension FeedCoordinator { ) let center = UNUserNotificationCenter.current() - center.getNotificationSettings { [weak self] settings in - guard let self, self.isAwaitingDecision(requestId: requestId) else { return } - switch settings.authorizationStatus { - case .authorized, .provisional: - self.addNotificationIfStillAwaiting( - center: center, - request: request, - requestId: requestId, - effects: effects - ) - case .notDetermined: - center.requestAuthorization(options: [.alert, .sound]) { - [weak self] granted, _ in - guard let self, self.isAwaitingDecision(requestId: requestId) else { - return - } - if granted { - self.addNotificationIfStillAwaiting( - center: center, - request: request, - requestId: requestId, - effects: effects - ) - } else { - runFallbackEffectsIfStillAwaiting() + center.getNotificationSettings { settings in + Task { @MainActor [weak self] in + guard let self, self.isAwaitingDecision(requestId: requestId) else { return } + switch settings.authorizationStatus { + case .authorized, .provisional: + self.addNotificationIfStillAwaiting( + center: center, + request: request, + requestId: requestId, + effects: effects + ) + case .notDetermined: + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + Task { @MainActor [weak self] in + guard let self, self.isAwaitingDecision(requestId: requestId) else { + return + } + if granted { + self.addNotificationIfStillAwaiting( + center: center, + request: request, + requestId: requestId, + effects: effects + ) + } else { + runFallbackEffectsIfStillAwaiting() + } + } } + default: + runFallbackEffectsIfStillAwaiting() } - default: - runFallbackEffectsIfStillAwaiting() } } } + @MainActor func addNotificationIfStillAwaiting( center: UNUserNotificationCenter, request: UNNotificationRequest, @@ -620,18 +631,20 @@ private extension FeedCoordinator { effects: TerminalNotificationPolicyEffects ) { guard isAwaitingDecision(requestId: requestId) else { return } - center.add(request) { [weak self] _ in - guard let self else { return } - if !self.isAwaitingDecision(requestId: requestId) { - self.cancelNotification(requestId: requestId) - return - } - if effects.command { - NotificationSoundSettings.runCustomCommand( - title: request.content.title, - subtitle: request.content.subtitle, - body: request.content.body - ) + center.add(request) { _ in + Task { @MainActor [weak self] in + guard let self else { return } + if !self.isAwaitingDecision(requestId: requestId) { + self.cancelNotification(requestId: requestId) + return + } + if effects.command { + NotificationSoundSettings.runCustomCommand( + title: request.content.title, + subtitle: request.content.subtitle, + body: request.content.body + ) + } } } } diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 14e5bc59b2d4..ab725ce7e314 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -83,6 +83,7 @@ final class FeedCoordinatorTests: XCTestCase { decision: .permission(.once) ) } + FeedCoordinatorTestHooks.isAppActiveOverride = { false } FeedCoordinatorTestHooks.notificationPostObserver = { _, postedRequestId in notifications.record(postedRequestId) } @@ -138,6 +139,7 @@ final class FeedCoordinatorTests: XCTestCase { let reset: @Sendable () -> Void = { MainActor.assumeIsolated { FeedCoordinatorTestHooks.afterBlockingEventIngested = nil + FeedCoordinatorTestHooks.isAppActiveOverride = nil FeedCoordinatorTestHooks.notificationPostObserver = nil } } From 5c61fb46b3487337957dbfa260ef64e7e7cfc222 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 15:23:56 -0700 Subject: [PATCH 13/14] fix: use async notification authorization --- Sources/Feed/FeedCoordinator.swift | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index b4ad0d6dce83..927eb92190c1 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -599,22 +599,19 @@ private extension FeedCoordinator { effects: effects ) case .notDetermined: - center.requestAuthorization(options: [.alert, .sound]) { granted, _ in - Task { @MainActor [weak self] in - guard let self, self.isAwaitingDecision(requestId: requestId) else { - return - } - if granted { - self.addNotificationIfStillAwaiting( - center: center, - request: request, - requestId: requestId, - effects: effects - ) - } else { - runFallbackEffectsIfStillAwaiting() - } - } + let granted = ( + try? await center.requestAuthorization(options: [.alert, .sound]) + ) ?? false + guard self.isAwaitingDecision(requestId: requestId) else { return } + if granted { + self.addNotificationIfStillAwaiting( + center: center, + request: request, + requestId: requestId, + effects: effects + ) + } else { + runFallbackEffectsIfStillAwaiting() } default: runFallbackEffectsIfStillAwaiting() From 1864d6272e1d599f5347bc58333c7eb78f28d703 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:17:32 -0700 Subject: [PATCH 14/14] fix: preserve feed notification fallback effects --- Sources/Feed/FeedCoordinator.swift | 81 ++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 11e20041864f..7f4658859973 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -550,22 +550,14 @@ private extension FeedCoordinator { effects.desktop || effects.sound || effects.command else { return } - func runFallbackEffectsIfStillAwaiting() { - guard self.isAwaitingDecision(requestId: requestId) else { return } - if effects.sound { - NotificationSoundSettings.playSelectedSound() - } - if effects.command { - NotificationSoundSettings.runCustomCommand( - title: title, - subtitle: subtitle, - body: body - ) - } - } - if !effects.desktop { - runFallbackEffectsIfStillAwaiting() + runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: effects + ) return } @@ -611,10 +603,22 @@ private extension FeedCoordinator { effects: effects ) } else { - runFallbackEffectsIfStillAwaiting() + self.runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: effects + ) } default: - runFallbackEffectsIfStillAwaiting() + self.runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: effects + ) } } } @@ -628,24 +632,59 @@ private extension FeedCoordinator { effects: TerminalNotificationPolicyEffects ) { guard isAwaitingDecision(requestId: requestId) else { return } - center.add(request) { _ in + let title = request.content.title + let subtitle = request.content.subtitle + let body = request.content.body + center.add(request) { error in + let didFail = error != nil Task { @MainActor [weak self] in guard let self else { return } if !self.isAwaitingDecision(requestId: requestId) { self.cancelNotification(requestId: requestId) return } + if didFail { + self.runFallbackEffectsIfStillAwaiting( + requestId: requestId, + title: title, + subtitle: subtitle, + body: body, + effects: effects + ) + return + } if effects.command { NotificationSoundSettings.runCustomCommand( - title: request.content.title, - subtitle: request.content.subtitle, - body: request.content.body + title: title, + subtitle: subtitle, + body: body ) } } } } + @MainActor + func runFallbackEffectsIfStillAwaiting( + requestId: String, + title: String, + subtitle: String, + body: String, + effects: TerminalNotificationPolicyEffects + ) { + guard isAwaitingDecision(requestId: requestId) else { return } + if effects.sound { + NotificationSoundSettings.playSelectedSound() + } + if effects.command { + NotificationSoundSettings.runCustomCommand( + title: title, + subtitle: subtitle, + body: body + ) + } + } + func cancelNotification(requestId: String) { let identifier = "feed.\(requestId)" let center = UNUserNotificationCenter.current()