diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 6d7c247dfad..2dd3ca03de2 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { NotificationResponse } from "expo-notifications"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; import { extractAgentNotificationDeepLink, @@ -18,6 +21,76 @@ function responseWithData(data: Record, identifier = "notificat }; } +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("consumeLastAgentNotificationResponse", () => { + it("reports which initial-response operation failed", async () => { + const cause = new Error("notification lookup unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.reject(cause), + clearLastResponse: () => Promise.resolve(), + handleResponse: vi.fn(), + }); + + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "read", + }), + ); + }); + + it("routes a response before reporting a clear failure", async () => { + const cause = new Error("notification clear unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({}, "notification-clear") as NotificationResponse; + const handleResponse = vi.fn(); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse: () => Promise.reject(cause), + handleResponse, + }); + + expect(handleResponse).toHaveBeenCalledWith(response); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "clear", + notificationId: "notification-clear", + }), + ); + }); + + it("reports routing failures before clearing the response", async () => { + const cause = new Error("notification routing unavailable"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const response = responseWithData({}, "notification-route") as NotificationResponse; + const clearLastResponse = vi.fn(() => Promise.resolve()); + + await consumeLastAgentNotificationResponse({ + getLastResponse: () => Promise.resolve(response), + clearLastResponse, + handleResponse: () => { + throw cause; + }, + }); + + expect(clearLastResponse).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + _tag: "NotificationNavigationError", + operation: "route", + notificationId: "notification-route", + }), + ); + }); +}); + describe("extractAgentNotificationDeepLink", () => { it("uses explicit deep links from APNs payload data", () => { expect( diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index a7027623653..18bb93d723e 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -3,6 +3,7 @@ import * as Notifications from "expo-notifications"; import { useRouter } from "expo-router"; import { routeAgentNotificationResponseOnce } from "./notificationPayload"; +import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; export function useAgentNotificationNavigation(): void { const router = useRouter(); @@ -18,15 +19,11 @@ export function useAgentNotificationNavigation(): void { }; const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); - void Notifications.getLastNotificationResponseAsync() - .then((response) => { - if (response) { - handleResponse(response); - return Notifications.clearLastNotificationResponseAsync(); - } - return undefined; - }) - .catch(() => undefined); + void consumeLastAgentNotificationResponse({ + getLastResponse: () => Notifications.getLastNotificationResponseAsync(), + clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(), + handleResponse, + }); return () => { subscription.remove(); diff --git a/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts b/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts new file mode 100644 index 00000000000..be6bfa820fa --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts @@ -0,0 +1,58 @@ +import type { NotificationResponse } from "expo-notifications"; +import * as Schema from "effect/Schema"; + +export class NotificationNavigationError extends Schema.TaggedErrorClass()( + "NotificationNavigationError", + { + operation: Schema.Literals(["read", "route", "clear"]), + notificationId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} the last notification response.`; + } +} + +export async function consumeLastAgentNotificationResponse(input: { + readonly getLastResponse: () => Promise; + readonly clearLastResponse: () => Promise; + readonly handleResponse: (response: NotificationResponse) => void; +}): Promise { + let response: NotificationResponse | null; + try { + response = await input.getLastResponse(); + } catch (cause) { + console.error(new NotificationNavigationError({ operation: "read", cause })); + return; + } + + if (!response) { + return; + } + + try { + input.handleResponse(response); + } catch (cause) { + console.error( + new NotificationNavigationError({ + operation: "route", + notificationId: response.notification.request.identifier, + cause, + }), + ); + return; + } + + try { + await input.clearLastResponse(); + } catch (cause) { + console.error( + new NotificationNavigationError({ + operation: "clear", + notificationId: response.notification.request.identifier, + cause, + }), + ); + } +}