Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";

/** Dismisses ended T3 cards still visible on the Lock Screen, preserving active and stale cards. */
export function dismissEndedAgentLiveActivities(): Promise<void> {
return AgentActivity.dismissEndedInstances();
}

/** Returns active and stale T3 cards; ended cards require the separate native dismissal pass. */
export function getAgentLiveActivities() {
return AgentActivity.getInstances();
}

/** Starts a T3 card while the app is foregrounded so its update token can be registered. */
export function startAgentLiveActivity(props: AgentActivityProps) {
return AgentActivity.start(props);
}
7 changes: 7 additions & 0 deletions apps/mobile/src/features/agent-awareness/agentLiveActivity.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { LiveActivity } from "expo-widgets";
import type { AgentActivityProps } from "../../widgets/AgentActivity";

/** Resolves without native work on platforms that do not support iOS Live Activities. */
export function dismissEndedAgentLiveActivities(): Promise<void> {
return Promise.resolve();
}

/** Returns no cards on platforms without iOS Live Activity support. */
export function getAgentLiveActivities(): Array<LiveActivity<AgentActivityProps>> {
return [];
}

/** Returns null without creating a card on platforms without iOS Live Activity support. */
export function startAgentLiveActivity(
_props: AgentActivityProps,
): LiveActivity<AgentActivityProps> | null {
Expand Down
175 changes: 175 additions & 0 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Constants from "expo-constants";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import {
Cookies,
Expand Down Expand Up @@ -62,6 +63,7 @@ vi.mock("./androidNotifications", () => ({

const secureStore = vi.hoisted(() => new Map<string, string>());
const widgetMocks = vi.hoisted(() => ({
dismissEndedInstances: vi.fn(() => Promise.resolve()),
getInstances: vi.fn(() => []),
start: vi.fn(() => ({})),
}));
Expand Down Expand Up @@ -102,6 +104,7 @@ vi.mock("expo-widgets", () => ({
}));

vi.mock("./agentLiveActivity", () => ({
dismissEndedAgentLiveActivities: widgetMocks.dismissEndedInstances,
getAgentLiveActivities: widgetMocks.getInstances,
startAgentLiveActivity: widgetMocks.start,
}));
Expand Down Expand Up @@ -274,6 +277,8 @@ describe("makeRelayDeviceRegistrationRequest", () => {
vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
widgetMocks.getInstances.mockReset();
widgetMocks.getInstances.mockReturnValue([]);
widgetMocks.dismissEndedInstances.mockReset();
widgetMocks.dismissEndedInstances.mockResolvedValue();
widgetMocks.start.mockClear();
environmentConfigsMock.configs.clear();
});
Expand Down Expand Up @@ -474,6 +479,176 @@ describe("makeRelayDeviceRegistrationRequest", () => {
},
);

it.effect(
"waits for ended-card cleanup before discovering and registering the active card",
() => {
const cleanup = Promise.withResolvers<void>();
const cleanupStarted = Promise.withResolvers<void>();
const activitiesRead = Promise.withResolvers<void>();
widgetMocks.getInstances.mockImplementation(() => {
activitiesRead.resolve();
return [];
});
widgetMocks.dismissEndedInstances.mockImplementation(() => {
cleanupStarted.resolve();
return cleanup.promise;
});
const activity = {
getPushToken: vi.fn(() => Promise.resolve("current-activity-token")),
addPushTokenListener: vi.fn(),
end: vi.fn(),
};
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));

return Effect.gen(function* () {
const refresh = yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.forkChild);
const firstStep = yield* Effect.promise(() =>
Promise.race([
cleanupStarted.promise.then(() => "cleanup"),
activitiesRead.promise.then(() => "activities"),
]),
);
expect(firstStep).toBe("cleanup");
expect(widgetMocks.getInstances).not.toHaveBeenCalled();
expect(widgetMocks.start).not.toHaveBeenCalled();

// Another foreground path armed the live card while native cleanup was
// pending. Discover it after cleanup instead of starting a second one.
widgetMocks.getInstances.mockReturnValue([activity] as never);
cleanup.resolve();
yield* Fiber.join(refresh);

expect(activity.getPushToken).toHaveBeenCalledOnce();
expect(activity.end).not.toHaveBeenCalled();
expect(widgetMocks.start).not.toHaveBeenCalled();
}).pipe(Effect.provide(relayTestLayer));
},
);

it.effect("still registers the active card when ended-card cleanup fails", () => {
widgetMocks.dismissEndedInstances.mockRejectedValueOnce(new Error("native cleanup failed"));
const activity = {
getPushToken: vi.fn(() => Promise.resolve("current-activity-token")),
addPushTokenListener: vi.fn(),
end: vi.fn(),
};
widgetMocks.getInstances.mockReturnValue([activity] as never);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));

return Effect.gen(function* () {
yield* refreshActiveLiveActivityRemoteRegistration();
expect(activity.getPushToken).toHaveBeenCalledOnce();
expect(activity.end).not.toHaveBeenCalled();
expect(widgetMocks.start).not.toHaveBeenCalled();
}).pipe(Effect.provide(relayTestLayer));
});

it("rechecks for an active card after cleanup when arming local work", async () => {
const cleanup = Promise.withResolvers<void>();
const cleanupStarted = Promise.withResolvers<void>();
const activitiesRead = Promise.withResolvers<void>();
widgetMocks.getInstances.mockImplementation(() => {
activitiesRead.resolve();
return [{ getPushToken: vi.fn() }] as never;
});
widgetMocks.dismissEndedInstances.mockImplementation(() => {
cleanupStarted.resolve();
return cleanup.promise;
});
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
vi.mocked(loadPreferences).mockResolvedValueOnce({
liveActivitiesEnabled: true,
} as Preferences);

armAgentAwarenessLiveActivityForLocalWork({
environmentId: "env-1" as EnvironmentId,
threadTitle: "New work",
projectTitle: "t3code",
});
expect(
await Promise.race([
cleanupStarted.promise.then(() => "cleanup"),
activitiesRead.promise.then(() => "activities"),
]),
).toBe("cleanup");
expect(widgetMocks.getInstances).not.toHaveBeenCalled();
expect(widgetMocks.start).not.toHaveBeenCalled();

cleanup.resolve();
await activitiesRead.promise;
expect(widgetMocks.start).not.toHaveBeenCalled();
});

it.each([
["preferences", "sign-out"],
["preferences", "account change"],
["preferences", "provider teardown"],
["cleanup", "sign-out"],
["cleanup", "account change"],
["cleanup", "provider teardown"],
])("cancels local arming pending %s after %s", async (pendingStep, sessionChange) => {
const resume = Promise.withResolvers<void>();
const paused = Promise.withResolvers<void>();
const preferences = { liveActivitiesEnabled: true } as Preferences;
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a");
if (pendingStep === "preferences") {
vi.mocked(loadPreferences).mockImplementationOnce(async () => {
paused.resolve();
await resume.promise;
return preferences;
});
} else {
vi.mocked(loadPreferences).mockResolvedValueOnce(preferences);
widgetMocks.dismissEndedInstances.mockImplementationOnce(() => {
paused.resolve();
return resume.promise;
});
}

const arming = armAgentAwarenessLiveActivityForLocalWork({
environmentId: "env-1" as EnvironmentId,
threadTitle: "Private work",
projectTitle: "Private project",
});
await paused.promise;
if (sessionChange === "sign-out") {
setAgentAwarenessRelayTokenProvider(null);
} else if (sessionChange === "account change") {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-b"), "user-b");
} else {
releaseAgentAwarenessRelayTokenProvider();
}
resume.resolve();
await arming;

expect(widgetMocks.start).not.toHaveBeenCalled();
});

it("still arms local work after a same-account token refresh during cleanup", async () => {
const cleanup = Promise.withResolvers<void>();
const cleanupStarted = Promise.withResolvers<void>();
widgetMocks.dismissEndedInstances.mockImplementationOnce(() => {
cleanupStarted.resolve();
return cleanup.promise;
});
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("token-a"), "user-a");
vi.mocked(loadPreferences).mockResolvedValueOnce({
liveActivitiesEnabled: true,
} as Preferences);

const arming = armAgentAwarenessLiveActivityForLocalWork({
environmentId: "env-1" as EnvironmentId,
threadTitle: "New work",
projectTitle: "t3code",
});
await cleanupStarted.promise;
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("refreshed-token-a"), "user-a");
cleanup.resolve();
await arming;

expect(widgetMocks.start).toHaveBeenCalledOnce();
});

it.effect(
"re-registers active Live Activity tokens when the app returns to the foreground",
() => {
Expand Down
78 changes: 56 additions & 22 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ import {
saveAgentAwarenessRegistrationRecord,
} from "../../persistence/imperative";
import type { AgentActivityProps } from "../../widgets/AgentActivity";
import { getAgentLiveActivities, startAgentLiveActivity } from "./agentLiveActivity";
import {
dismissEndedAgentLiveActivities,
getAgentLiveActivities,
startAgentLiveActivity,
} from "./agentLiveActivity";
import { resolveCloudPublicConfig } from "../cloud/publicConfig";
import { supportsAgentAwarenessPush } from "./capabilities";
import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
Expand Down Expand Up @@ -487,18 +491,17 @@ function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolea
);
}

// Arms the lock-screen card the moment the user starts agent work from this
// phone, while the app is still foregrounded and the fresh activity's token
// can be registered immediately. The seeded row is a best-effort placeholder;
// the relay's registration replay repaints it with the authoritative
// aggregate within seconds. No-ops when a card is already armed, and skips
// environments that report publishing disabled — the seed would sit on
// "Connecting" forever with no update ever arriving to repaint or end it.
export function armAgentAwarenessLiveActivityForLocalWork(input: {
/**
* Arms a foreground card for local work; relay registration replaces its placeholder
* with the current aggregate. Skips disabled preferences, existing cards, and
* environments that cannot publish updates. Session changes cancel pending arming.
* Resolves after the arming attempt; token registration continues in the background.
*/
export async function armAgentAwarenessLiveActivityForLocalWork(input: {
readonly environmentId: EnvironmentId;
readonly threadTitle: string;
readonly projectTitle: string;
}): void {
}): Promise<void> {
if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
return;
}
Expand All @@ -508,21 +511,32 @@ export function armAgentAwarenessLiveActivityForLocalWork(input: {
});
return;
}
void loadPreferences()
.catch(() => null)
.then((preferences) => {
if (preferences?.liveActivitiesEnabled === false) {
return;
}
armAgentAwarenessLiveActivityForLocalWorkNow(input);
});
const generation = deviceRegistrationGeneration;
const preferences = await loadPreferences().catch(() => null);
if (preferences?.liveActivitiesEnabled === false || generation !== deviceRegistrationGeneration) {
return;
}
await armAgentAwarenessLiveActivityForLocalWorkNow(input, generation);
}

function armAgentAwarenessLiveActivityForLocalWorkNow(input: {
readonly threadTitle: string;
readonly projectTitle: string;
}): void {
/**
* Removes ended cards before seeding local work for the captured session generation.
* Rechecks the session and existing cards after cleanup to avoid orphaned or duplicate cards.
*/
async function armAgentAwarenessLiveActivityForLocalWorkNow(
input: {
readonly threadTitle: string;
readonly projectTitle: string;
},
expectedGeneration: number,
): Promise<void> {
try {
await dismissEndedLocalLiveActivities();
// Sign-out or an account change may have happened while native cleanup ran.
// The old session must not leave a new card that its relay can no longer end.
if (expectedGeneration !== deviceRegistrationGeneration) {
return;
}
if (getAgentLiveActivities().length > 0) {
return;
}
Expand Down Expand Up @@ -841,10 +855,24 @@ function ensureAppStateListener(): void {
});
}

/**
* Dismisses ended cards hidden by getInstances() but still visible on the Lock Screen.
* Logs native cleanup failures so active-card registration can continue.
*/
async function dismissEndedLocalLiveActivities(): Promise<void> {
try {
await dismissEndedAgentLiveActivities();
} catch (error) {
logRegistrationError("ended live activity cleanup failed", error);
}
}

/** Starts best-effort dismissal of ended and live cards on sign-out, logging failures with context. */
function endLocalLiveActivities(context: string): void {
if (!canRegisterRemoteLiveActivities()) {
return;
}
void dismissEndedLocalLiveActivities();
try {
for (const activity of getAgentLiveActivities()) {
activity.end("immediate").catch((error: unknown) => {
Expand Down Expand Up @@ -1062,6 +1090,10 @@ function scheduleActiveLiveActivityRegistrationRetry(): void {
}, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS);
}

/**
* Reconciles foreground iOS cards with the relay after dismissing ended cards.
* Keeps one live card, primes it from current work when needed, and registers its update token.
*/
export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
void,
never,
Expand All @@ -1072,6 +1104,8 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
return;
}

yield* Effect.promise(dismissEndedLocalLiveActivities);

let activities = yield* Effect.try({
try: () => getAgentLiveActivities(),
catch: (cause) =>
Expand Down
Loading
Loading