From af93b5deb2d0079df4e987bf773fb74cf5aa5f7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 05:23:25 -0700 Subject: [PATCH 1/2] [codex] Structure agent awareness operation errors Co-authored-by: codex --- .../liveActivityPreferences.test.ts | 28 ++++- .../notificationPermissions.test.ts | 60 ++++++++++ .../notificationPermissions.ts | 12 ++ .../remoteRegistration.test.ts | 23 ++++ .../agent-awareness/remoteRegistration.ts | 107 ++++++++++++++---- 5 files changed, 209 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 5de14ea76fc..ea77d4832c0 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -9,7 +9,10 @@ import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; import { savePreferencesPatch } from "../../lib/storage"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; -import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; +import { + LiveActivityPreferencePersistenceError, + setLiveActivityUpdatesEnabled, +} from "./liveActivityPreferences"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; vi.mock("../../lib/storage", () => ({ @@ -95,6 +98,29 @@ describe("liveActivityPreferences", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("preserves preference persistence failures", () => { + const cause = new Error("secure storage unavailable"); + vi.mocked(savePreferencesPatch).mockRejectedValueOnce(cause); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + setLiveActivityUpdatesEnabled({ + enabled: false, + clerkToken: null, + connections: [], + }), + ); + + expect(error).toBeInstanceOf(LiveActivityPreferencePersistenceError); + expect(error).toMatchObject({ + _tag: "LiveActivityPreferencePersistenceError", + enabled: false, + cause, + message: "Failed to save Live Activity update preferences.", + }); + }).pipe(Effect.provide(testLayer)); + }); + it.effect("does not try to re-link managed relay connections without bearer credentials", () => { const managedConnection: SavedRemoteConnection = { ...connection, diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts new file mode 100644 index 00000000000..d01b3d0a51c --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Notifications from "expo-notifications"; +import { vi } from "vite-plus/test"; + +import { + AgentNotificationPermissionError, + requestAgentNotificationPermission, +} from "./notificationPermissions"; + +vi.mock("expo-notifications", () => ({ + getPermissionsAsync: vi.fn(), + requestPermissionsAsync: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Platform: { + OS: "ios", + }, +})); + +describe("requestAgentNotificationPermission", () => { + it.effect("preserves permission lookup failures", () => { + const cause = new Error("notification service unavailable"); + vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(cause); + + return Effect.gen(function* () { + const error = yield* Effect.flip(requestAgentNotificationPermission); + + expect(error).toBeInstanceOf(AgentNotificationPermissionError); + expect(error).toMatchObject({ + _tag: "AgentNotificationPermissionError", + operation: "read", + cause, + message: "Failed to read agent notification permissions.", + }); + }); + }); + + it.effect("preserves permission request failures", () => { + const cause = new Error("permission prompt unavailable"); + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ + granted: false, + canAskAgain: true, + } as never); + vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(cause); + + return Effect.gen(function* () { + const error = yield* Effect.flip(requestAgentNotificationPermission); + + expect(error).toBeInstanceOf(AgentNotificationPermissionError); + expect(error).toMatchObject({ + _tag: "AgentNotificationPermissionError", + operation: "request", + cause, + message: "Failed to request agent notification permissions.", + }); + }); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index dc275774a50..84e35dfbeb4 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -3,6 +3,18 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { Platform } from "react-native"; +export class AgentNotificationPermissionError extends Schema.TaggedErrorClass()( + "AgentNotificationPermissionError", + { + operation: Schema.Literals(["read", "request"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} agent notification permissions.`; + } +} + export type NotificationPermissionResult = | { readonly type: "unsupported" } | { readonly type: "granted" } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 43d62b81622..7f97d7c718c 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -18,6 +18,7 @@ import { cryptoLayer } from "../cloud/dpop"; import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { + AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, @@ -267,6 +268,28 @@ describe("makeRelayDeviceRegistrationRequest", () => { }).pipe(Effect.provide(relayTestLayer)); }); + it.effect("preserves Live Activity push-token lookup failures", () => { + const cause = new Error("native token lookup failed"); + const activity = { + getPushToken: vi.fn(() => Promise.reject(cause)), + addPushTokenListener: vi.fn(), + }; + + return Effect.gen(function* () { + const error = yield* Effect.flip( + registerLiveActivityPushToken({ activity: activity as never }), + ); + + expect(error).toBeInstanceOf(AgentAwarenessOperationError); + expect(error).toMatchObject({ + _tag: "AgentAwarenessOperationError", + operation: "read-live-activity-push-token", + cause, + message: "Agent awareness operation read-live-activity-push-token failed.", + }); + }).pipe(Effect.provide(relayTestLayer)); + }); + it.effect( "reports Live Activity token registration as skipped when relay auth is unavailable", () => { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 98e38c74055..3281381e0e1 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -2,6 +2,7 @@ import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Platform } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { @@ -28,6 +29,33 @@ import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; + +const AgentAwarenessOperation = Schema.Literals([ + "read-notification-permissions", + "read-native-push-token", + "read-device-registration-relay-token", + "read-device-unregistration-relay-token", + "read-live-activity-registration-relay-token", + "load-device-registration-identifier", + "load-device-registration-preferences", + "load-device-unregistration-identifier", + "read-live-activity-push-token", + "load-live-activity-registration-identifier", + "list-active-live-activities", +]); + +export class AgentAwarenessOperationError extends Schema.TaggedErrorClass()( + "AgentAwarenessOperationError", + { + operation: AgentAwarenessOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Agent awareness operation ${this.operation} failed.`; + } +} + const environmentConnections = new Map(); const activityPushTokenListeners = new WeakSet>(); let pushToStartSubscription: { remove: () => void } | null = null; @@ -137,14 +165,22 @@ function nativePushTokenRegistration(observedPushToken?: string) { } const permissions = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-notification-permissions", + cause, + }), }); if (!permissions.granted) { return { notificationsEnabled: false, pushToken: null }; } const token = yield* Effect.tryPromise({ try: () => Notifications.getDevicePushTokenAsync(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-native-push-token", + cause, + }), }).pipe( Effect.tapError((error) => Effect.sync(() => { @@ -161,16 +197,19 @@ function nativePushTokenRegistration(observedPushToken?: string) { }); } -const relayToken = Effect.gen(function* () { - const provider = relayTokenProvider; - if (!provider) { - return null; - } - return yield* Effect.tryPromise({ - try: provider, - catch: (error) => error, +const relayToken = ( + operation: "read-device-registration-relay-token" | "read-live-activity-registration-relay-token", +) => + Effect.gen(function* () { + const provider = relayTokenProvider; + if (!provider) { + return null; + } + return yield* Effect.tryPromise({ + try: provider, + catch: (cause) => new AgentAwarenessOperationError({ operation, cause }), + }); }); -}); function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, @@ -185,7 +224,7 @@ function registerDeviceWithRelay( return; } if (!readRelayConfig()) return; - const token = yield* relayToken; + const token = yield* relayToken("read-device-registration-relay-token"); if (expectedGeneration !== deviceRegistrationGeneration) { logRegistrationDebug("device registration cancelled after auth lookup", { expectedGeneration, @@ -220,7 +259,11 @@ function unregisterDeviceWithRelay(input: { if (!readRelayConfig()) return; const token = yield* Effect.tryPromise({ try: input.tokenProvider, - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-device-unregistration-relay-token", + cause, + }), }); if (!token) { logRegistrationDebug("relay device unregistration skipped; user is not signed in"); @@ -240,7 +283,7 @@ function registerLiveActivityWithRelay( ): Effect.Effect { return Effect.gen(function* () { if (!readRelayConfig()) return false; - const token = yield* relayToken; + const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { logRegistrationDebug("relay live activity registration skipped; user is not signed in"); return false; @@ -381,11 +424,19 @@ function registerDevice( const [deviceId, preferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-identifier", + cause, + }), }), Effect.tryPromise({ try: () => loadPreferences(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-registration-preferences", + cause, + }), }), ]); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); @@ -519,7 +570,11 @@ export function unregisterAgentAwarenessDeviceForCurrentUser( return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-device-unregistration-identifier", + cause, + }), }); if (!deviceId) { return; @@ -544,7 +599,11 @@ export function registerLiveActivityPushToken(input: { const activityPushToken = yield* Effect.tryPromise({ try: () => input.activity.getPushToken(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "read-live-activity-push-token", + cause, + }), }); if (!activityPushToken) { if (activityPushTokenListeners.has(input.activity)) { @@ -592,7 +651,11 @@ function registerLiveActivityPushTokenValue(input: { return Effect.gen(function* () { const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "load-live-activity-registration-identifier", + cause, + }), }); const registered = yield* registerLiveActivityWithRelay({ deviceId, @@ -633,7 +696,11 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< const activities = yield* Effect.try({ try: () => AgentActivity.getInstances(), - catch: (error) => error, + catch: (cause) => + new AgentAwarenessOperationError({ + operation: "list-active-live-activities", + cause, + }), }).pipe( Effect.catch((error) => Effect.sync(() => { From eee91127d5a851f0fadf26684186be508dddd261 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:53:18 -0700 Subject: [PATCH 2/2] Narrow agent awareness error audit Co-authored-by: codex --- .../liveActivityPreferences.test.ts | 28 +-------- .../notificationPermissions.test.ts | 60 ------------------- .../notificationPermissions.ts | 12 ---- 3 files changed, 1 insertion(+), 99 deletions(-) delete mode 100644 apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index ea77d4832c0..5de14ea76fc 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -9,10 +9,7 @@ import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; import { savePreferencesPatch } from "../../lib/storage"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; -import { - LiveActivityPreferencePersistenceError, - setLiveActivityUpdatesEnabled, -} from "./liveActivityPreferences"; +import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; vi.mock("../../lib/storage", () => ({ @@ -98,29 +95,6 @@ describe("liveActivityPreferences", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("preserves preference persistence failures", () => { - const cause = new Error("secure storage unavailable"); - vi.mocked(savePreferencesPatch).mockRejectedValueOnce(cause); - - return Effect.gen(function* () { - const error = yield* Effect.flip( - setLiveActivityUpdatesEnabled({ - enabled: false, - clerkToken: null, - connections: [], - }), - ); - - expect(error).toBeInstanceOf(LiveActivityPreferencePersistenceError); - expect(error).toMatchObject({ - _tag: "LiveActivityPreferencePersistenceError", - enabled: false, - cause, - message: "Failed to save Live Activity update preferences.", - }); - }).pipe(Effect.provide(testLayer)); - }); - it.effect("does not try to re-link managed relay connections without bearer credentials", () => { const managedConnection: SavedRemoteConnection = { ...connection, diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts deleted file mode 100644 index d01b3d0a51c..00000000000 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Notifications from "expo-notifications"; -import { vi } from "vite-plus/test"; - -import { - AgentNotificationPermissionError, - requestAgentNotificationPermission, -} from "./notificationPermissions"; - -vi.mock("expo-notifications", () => ({ - getPermissionsAsync: vi.fn(), - requestPermissionsAsync: vi.fn(), -})); - -vi.mock("react-native", () => ({ - Platform: { - OS: "ios", - }, -})); - -describe("requestAgentNotificationPermission", () => { - it.effect("preserves permission lookup failures", () => { - const cause = new Error("notification service unavailable"); - vi.mocked(Notifications.getPermissionsAsync).mockRejectedValueOnce(cause); - - return Effect.gen(function* () { - const error = yield* Effect.flip(requestAgentNotificationPermission); - - expect(error).toBeInstanceOf(AgentNotificationPermissionError); - expect(error).toMatchObject({ - _tag: "AgentNotificationPermissionError", - operation: "read", - cause, - message: "Failed to read agent notification permissions.", - }); - }); - }); - - it.effect("preserves permission request failures", () => { - const cause = new Error("permission prompt unavailable"); - vi.mocked(Notifications.getPermissionsAsync).mockResolvedValueOnce({ - granted: false, - canAskAgain: true, - } as never); - vi.mocked(Notifications.requestPermissionsAsync).mockRejectedValueOnce(cause); - - return Effect.gen(function* () { - const error = yield* Effect.flip(requestAgentNotificationPermission); - - expect(error).toBeInstanceOf(AgentNotificationPermissionError); - expect(error).toMatchObject({ - _tag: "AgentNotificationPermissionError", - operation: "request", - cause, - message: "Failed to request agent notification permissions.", - }); - }); - }); -}); diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index 84e35dfbeb4..dc275774a50 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -3,18 +3,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { Platform } from "react-native"; -export class AgentNotificationPermissionError extends Schema.TaggedErrorClass()( - "AgentNotificationPermissionError", - { - operation: Schema.Literals(["read", "request"]), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to ${this.operation} agent notification permissions.`; - } -} - export type NotificationPermissionResult = | { readonly type: "unsupported" } | { readonly type: "granted" }