authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:13:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:13:04-07:00
logd7718e81e0af33d0a2d0f515385ac210f352fffb
tree5a7c00151c9183a061cb61574d1a2ee1205cc96f
parentb88873508e8dedbff772b9835604d66c8c72c17c
signature Signed by PGP key B5690EEEBB952194

[codex] Structure mobile notification navigation failures (#3359)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 138 insertions(+), 10 deletions(-)

apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts+74-1
......@@ -1,4 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";
1import type { NotificationResponse } from "expo-notifications";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
25
36import {
47 extractAgentNotificationDeepLink,
......@@ -18,6 +21,76 @@ function responseWithData(data: Record<string, unknown>, identifier = "notificat
1821 };
1922}
2023
24afterEach(() => {
25 vi.restoreAllMocks();
26});
27
28describe("consumeLastAgentNotificationResponse", () => {
29 it("reports which initial-response operation failed", async () => {
30 const cause = new Error("notification lookup unavailable");
31 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
32
33 await consumeLastAgentNotificationResponse({
34 getLastResponse: () => Promise.reject(cause),
35 clearLastResponse: () => Promise.resolve(),
36 handleResponse: vi.fn(),
37 });
38
39 expect(consoleError).toHaveBeenCalledWith(
40 expect.objectContaining({
41 _tag: "NotificationNavigationError",
42 operation: "read",
43 }),
44 );
45 });
46
47 it("routes a response before reporting a clear failure", async () => {
48 const cause = new Error("notification clear unavailable");
49 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
50 const response = responseWithData({}, "notification-clear") as NotificationResponse;
51 const handleResponse = vi.fn();
52
53 await consumeLastAgentNotificationResponse({
54 getLastResponse: () => Promise.resolve(response),
55 clearLastResponse: () => Promise.reject(cause),
56 handleResponse,
57 });
58
59 expect(handleResponse).toHaveBeenCalledWith(response);
60 expect(consoleError).toHaveBeenCalledWith(
61 expect.objectContaining({
62 _tag: "NotificationNavigationError",
63 operation: "clear",
64 notificationId: "notification-clear",
65 }),
66 );
67 });
68
69 it("reports routing failures before clearing the response", async () => {
70 const cause = new Error("notification routing unavailable");
71 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
72 const response = responseWithData({}, "notification-route") as NotificationResponse;
73 const clearLastResponse = vi.fn(() => Promise.resolve());
74
75 await consumeLastAgentNotificationResponse({
76 getLastResponse: () => Promise.resolve(response),
77 clearLastResponse,
78 handleResponse: () => {
79 throw cause;
80 },
81 });
82
83 expect(clearLastResponse).not.toHaveBeenCalled();
84 expect(consoleError).toHaveBeenCalledWith(
85 expect.objectContaining({
86 _tag: "NotificationNavigationError",
87 operation: "route",
88 notificationId: "notification-route",
89 }),
90 );
91 });
92});
93
2194describe("extractAgentNotificationDeepLink", () => {
2295 it("uses explicit deep links from APNs payload data", () => {
2396 expect(
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+6-9
......@@ -3,6 +3,7 @@ import * as Notifications from "expo-notifications";
33import { useRouter } from "expo-router";
44
55import { routeAgentNotificationResponseOnce } from "./notificationPayload";
6import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";
67
78export function useAgentNotificationNavigation(): void {
89 const router = useRouter();
......@@ -18,15 +19,11 @@ export function useAgentNotificationNavigation(): void {
1819 };
1920
2021 const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse);
21 void Notifications.getLastNotificationResponseAsync()
22 .then((response) => {
23 if (response) {
24 handleResponse(response);
25 return Notifications.clearLastNotificationResponseAsync();
26 }
27 return undefined;
28 })
29 .catch(() => undefined);
22 void consumeLastAgentNotificationResponse({
23 getLastResponse: () => Notifications.getLastNotificationResponseAsync(),
24 clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(),
25 handleResponse,
26 });
3027
3128 return () => {
3229 subscription.remove();
apps/mobile/src/features/agent-awareness/notificationResponseConsumer.ts created+58
......@@ -0,0 +1,58 @@
1import type { NotificationResponse } from "expo-notifications";
2import * as Schema from "effect/Schema";
3
4export class NotificationNavigationError extends Schema.TaggedErrorClass<NotificationNavigationError>()(
5 "NotificationNavigationError",
6 {
7 operation: Schema.Literals(["read", "route", "clear"]),
8 notificationId: Schema.optional(Schema.String),
9 cause: Schema.Defect(),
10 },
11) {
12 override get message(): string {
13 return `Failed to ${this.operation} the last notification response.`;
14 }
15}
16
17export async function consumeLastAgentNotificationResponse(input: {
18 readonly getLastResponse: () => Promise<NotificationResponse | null>;
19 readonly clearLastResponse: () => Promise<void>;
20 readonly handleResponse: (response: NotificationResponse) => void;
21}): Promise<void> {
22 let response: NotificationResponse | null;
23 try {
24 response = await input.getLastResponse();
25 } catch (cause) {
26 console.error(new NotificationNavigationError({ operation: "read", cause }));
27 return;
28 }
29
30 if (!response) {
31 return;
32 }
33
34 try {
35 input.handleResponse(response);
36 } catch (cause) {
37 console.error(
38 new NotificationNavigationError({
39 operation: "route",
40 notificationId: response.notification.request.identifier,
41 cause,
42 }),
43 );
44 return;
45 }
46
47 try {
48 await input.clearLastResponse();
49 } catch (cause) {
50 console.error(
51 new NotificationNavigationError({
52 operation: "clear",
53 notificationId: response.notification.request.identifier,
54 cause,
55 }),
56 );
57 }
58}