Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5a1c6d9
fix(mobile): iOS Agent Activity widget renders instead of a blank tile
Oct 18, 2026
25247c6
fix(mobile): reconcile Live Activities before the widget's relay read
Oct 19, 2026
f7773cf
fix(mobile): a slow older widget read no longer overwrites newer rows
Oct 19, 2026
31c97b0
fix(mobile): widget hides the previous account's agents on account sw…
Oct 19, 2026
f3ddbac
Merge main into fix/ios-agent-activity-widget
shivamhwp Oct 21, 2026
1ba2c18
fix(mobile): widget keeps its rows across a cold start and never re-p…
shivamhwp Oct 21, 2026
9a71d10
fix(mobile): a failed read after a cold start never leaves a stale wi…
shivamhwp Oct 21, 2026
49a8baa
fix(mobile): widget rows keep their owner across a provider release
shivamhwp Oct 21, 2026
a736547
fix(mobile): only the newest widget read may prime a Live Activity
shivamhwp Oct 21, 2026
217c694
Merge remote-tracking branch 'github/main' into fix/ios-agent-activit…
maria-rcks Oct 26, 2026
9644c16
fix(mobile): preserve account widget rows across cold starts
maria-rcks Oct 26, 2026
ca9b8d5
fix(mobile): omit absent widget owners from native storage
maria-rcks Oct 26, 2026
13aef01
chore: merge main into ios agent activity widget
maria-rcks Oct 30, 2026
c27c70e
fix(mobile): keep widget checks compatible with main
maria-rcks Oct 30, 2026
e34eaaf
Merge remote-tracking branch 'origin/main' into prbatch/21163
shivamhwp Nov 7, 2026
f070681
fix(mobile): drop the widget's account cache and guard reads by regis…
shivamhwp Nov 7, 2026
9e1694d
test(mobile): cover a sign-out and re-sign-in during a widget read
shivamhwp Nov 7, 2026
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
7 changes: 6 additions & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,12 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
displayName: "Agent Activity",
description: "Shows the current state of active T3 Code agents.",
android: null,
ios: { supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"] },
ios: {
supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],
// Embeds the layout so the gallery preview and a new widget render
// before the app has run once; the app replaces it on publish.
initialLayout: "./src/widgets/AgentActivityWidget.tsx",
},
},
],
},
Expand Down
282 changes: 275 additions & 7 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,31 @@ import { describe, expect, it } from "@effect/vitest";
import Constants from "expo-constants";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import { Cookies, FetchHttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
import { ManagedRelay } from "@t3tools/client-runtime/relay";

import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
import { verifyDpopProof } from "@t3tools/shared/dpop";
import * as Dpop from "../cloud/dpop";
import * as ManagedRelayLayer from "../cloud/managedRelayLayer";
import {
clearAgentAwarenessRegistrationRecord,
loadAgentAwarenessRegistrationRecord,
loadOrCreateAgentAwarenessDeviceId,
loadPreferences,
saveAgentAwarenessRegistrationRecord,
} from "../../persistence/imperative";
import type { Preferences } from "../../persistence/mobile-preferences";
import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
import {
__resetAgentAwarenessRemoteRegistrationForTest,
getAgentAwarenessRegistrationStatus,
refreshActiveLiveActivityRemoteRegistration,
refreshAgentAwarenessRegistration,
registerLiveActivityPushToken,
releaseAgentAwarenessRelayTokenProvider,
Expand All @@ -49,6 +55,7 @@ const widgetMocks = vi.hoisted(() => ({
getInstances: vi.fn(() => []),
start: vi.fn(() => ({})),
}));
const publishWidgetMock = vi.hoisted(() => vi.fn((_source: unknown) => Promise.resolve()));
const environmentConfigsMock = vi.hoisted(() => ({
configs: new Map<
string,
Expand Down Expand Up @@ -85,6 +92,10 @@ vi.mock("expo-widgets", () => ({
addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })),
}));

vi.mock("../../widgets/publishAgentActivityWidget", () => ({
publishAgentActivityWidget: publishWidgetMock,
}));

vi.mock("./agentLiveActivity", () => ({
dismissEndedAgentLiveActivities: widgetMocks.dismissEnded,
getAgentLiveActivities: widgetMocks.getInstances,
Expand Down Expand Up @@ -252,6 +263,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
widgetMocks.getInstances.mockReset();
widgetMocks.getInstances.mockReturnValue([]);
widgetMocks.start.mockClear();
publishWidgetMock.mockClear();
widgetMocks.dismissEnded.mockReset();
environmentConfigsMock.configs.clear();
});
Expand Down Expand Up @@ -520,12 +532,14 @@ describe("makeRelayDeviceRegistrationRequest", () => {
return Effect.gen(function* () {
yield* runBackgroundOperations();

expect(fetchMock).toHaveBeenCalledTimes(2);
const [request, init] = fetchMock.mock.calls[1] as unknown as [
unknown,
RequestInit | undefined,
];
const url = request instanceof Request ? request.url : String(request);
const requestUrl = (request: unknown) =>
request instanceof Request ? request.url : String(request);
const deviceCalls = fetchMock.mock.calls.filter(([request]) =>
requestUrl(request).endsWith("/v1/mobile/devices"),
);
expect(deviceCalls).toHaveLength(1);
const [request, init] = deviceCalls[0] as unknown as [unknown, RequestInit | undefined];
const url = requestUrl(request);
const method = request instanceof Request ? request.method : init?.method;
const headers = request instanceof Request ? request.headers : new Headers(init?.headers);
const dpop = headers.get("dpop");
Expand Down Expand Up @@ -597,9 +611,12 @@ describe("makeRelayDeviceRegistrationRequest", () => {
},
};

// Sign-in first reads the widget snapshot, then registers the device; fail
// both lookups so the queued registration is the one that must recover.
const tokenProvider = vi
.fn<() => Promise<string | null>>()
.mockRejectedValueOnce(new Error("auth unavailable"))
.mockRejectedValueOnce(new Error("auth unavailable"))
.mockResolvedValue("clerk-token-user-a");
setAgentAwarenessRelayTokenProvider(tokenProvider);
const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0];
Expand All @@ -610,7 +627,7 @@ describe("makeRelayDeviceRegistrationRequest", () => {
yield* runBackgroundOperations();

expect(backgroundRuntime.pending).toHaveLength(0);
expect(tokenProvider).toHaveBeenCalledTimes(2);
expect(tokenProvider).toHaveBeenCalledTimes(3);
}).pipe(Effect.provide(layerRelayTest));
});

Expand Down Expand Up @@ -728,4 +745,255 @@ describe("makeRelayDeviceRegistrationRequest", () => {
}).pipe(Effect.provide(layerRelayTest));
},
);

describe("home-screen widget", () => {
// These cover the widget path only, so keep the Live Activity from priming.
beforeEach(() => {
vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false } as Preferences);
});

const aggregate: RelayAgentActivityAggregateState = {
title: "T3 Code",
subtitle: "Agent work in progress",
activeCount: 1,
updatedAt: "2026-10-18T10:00:00.000Z",
activities: [
{
environmentId: "env-1" as EnvironmentId,
threadId: "thread-1" as ThreadId,
projectTitle: "t3code",
threadTitle: "Fix the widget",
modelTitle: "GPT-5.5",
phase: "running",
status: "Working",
updatedAt: "2026-10-18T10:00:00.000Z",
deepLink: "/threads/env-1/thread-1",
},
],
};

// The shared fetch-backed relay layer keeps the first test's fetch stub,
// so these tests answer the snapshot read through a fake client instead.
function fakeRelay(
getAgentActivitySnapshot: () => Effect.Effect<
{ readonly aggregate: typeof aggregate },
ManagedRelay.ManagedRelayRequestFailedError
>,
) {
Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } };
const unexpected = () => Effect.die("Unexpected relay request");
return Layer.succeed(
ManagedRelay.ManagedRelayClient,
ManagedRelay.ManagedRelayClient.of({
relayUrl: "https://relay.example.test",
listEnvironments: unexpected,
listDevices: unexpected,
getT3ChatLink: unexpected,
createT3ChatLinkRequest: unexpected,
unlinkT3Chat: unexpected,
createEnvironmentLinkChallenge: unexpected,
linkEnvironment: unexpected,
unlinkEnvironment: unexpected,
getEnvironmentStatus: unexpected,
connectEnvironment: unexpected,
registerDevice: unexpected,
unregisterDevice: unexpected,
registerLiveActivity: unexpected,
getAgentActivitySnapshot,
resetTokenCache: Effect.void,
}),
);
}

it.effect("registers an existing Live Activity before waiting on the widget read", () => {
const order: Array<string> = [];
const activity = {
getId: () => "activity-1",
getPushToken: vi.fn(() => {
order.push("push-token");
return Promise.resolve(null);
}),
addPushTokenListener: vi.fn(),
};
widgetMocks.getInstances.mockReturnValue([activity] as never);
const relay = fakeRelay(() =>
Effect.sync(() => {
order.push("widget-snapshot");
return { aggregate };
}),
);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");

return Effect.gen(function* () {
yield* refreshActiveLiveActivityRemoteRegistration();

expect(order).toEqual(["push-token", "widget-snapshot"]);
expect(publishWidgetMock).toHaveBeenLastCalledWith({
kind: "snapshot",
aggregate,
});
}).pipe(Effect.provide(relay));
});

it.effect("keeps the newest rows when an older overlapping read finishes last", () =>
Effect.gen(function* () {
const newer = { ...aggregate, activeCount: 2 };
const olderRequested = yield* Deferred.make<void>();
const olderResponse = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>();
let reads = 0;
const relay = fakeRelay(() => {
reads++;
return reads === 1
? Deferred.succeed(olderRequested, undefined).pipe(
Effect.andThen(Deferred.await(olderResponse)),
)
: Effect.succeed({ aggregate: newer });
});
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");

const older = yield* Effect.forkChild(
refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
);
yield* Deferred.await(olderRequested);
yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay));
yield* Deferred.succeed(olderResponse, { aggregate });
yield* Fiber.join(older);

expect(publishWidgetMock).toHaveBeenLastCalledWith({
kind: "snapshot",
aggregate: newer,
});
}),
);

it.effect("an obsolete refresh does not discard the next account's read", () =>
Effect.gen(function* () {
let markTokenRequested = () => {};
const tokenRequested = new Promise<void>((resolve) => {
markTokenRequested = resolve;
});
let releaseToken = () => {};
widgetMocks.getInstances.mockReturnValueOnce([
{
getId: () => "activity-a",
getPushToken: () => {
markTokenRequested();
return new Promise<null>((resolve) => {
releaseToken = () => resolve(null);
});
},
addPushTokenListener: vi.fn(),
},
] as never);
const requested = yield* Deferred.make<void>();
const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>();
const relay = fakeRelay(() =>
Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))),
);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a");

const refreshA = yield* Effect.forkChild(
refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
);
yield* Effect.promise(() => tokenRequested);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-b"), "user-b");
const refreshB = yield* Effect.forkChild(
refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
);
yield* Deferred.await(requested);
releaseToken();
yield* Fiber.join(refreshA);
yield* Deferred.succeed(response, { aggregate });
yield* Fiber.join(refreshB);

expect(publishWidgetMock).toHaveBeenLastCalledWith({
kind: "snapshot",
aggregate,
});
}),
);

it.effect("does not prime a Live Activity from a read a newer one replaced", () =>
Effect.gen(function* () {
let markRequested = () => {};
const requested = new Promise<void>((resolve) => {
markRequested = resolve;
});
let resolvePreferences = (_preferences: Preferences) => {};
vi.mocked(loadPreferences).mockImplementationOnce(() => {
markRequested();
return new Promise((resolve) => {
resolvePreferences = resolve;
});
});
const idle = { ...aggregate, activeCount: 0, activities: [] };
let reads = 0;
const relay = fakeRelay(() =>
Effect.succeed({ aggregate: ++reads === 1 ? aggregate : idle }),
);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");

const older = yield* Effect.forkChild(
refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
);
yield* Effect.promise(() => requested);
yield* refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay));
resolvePreferences({ liveActivitiesEnabled: true } as Preferences);
yield* Fiber.join(older);

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

it.effect("drops a snapshot that arrives after a sign-out, even on re-sign-in", () =>
Effect.gen(function* () {
const requested = yield* Deferred.make<void>();
const response = yield* Deferred.make<{ readonly aggregate: typeof aggregate }>();
const relay = fakeRelay(() =>
Deferred.succeed(requested, undefined).pipe(Effect.andThen(Deferred.await(response))),
);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a");

const refresh = yield* Effect.forkChild(
refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
);
yield* Deferred.await(requested);
setAgentAwarenessRelayTokenProvider(null);
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-a"), "user-a");
publishWidgetMock.mockClear();
yield* Deferred.succeed(response, { aggregate });
yield* Fiber.join(refresh);

expect(publishWidgetMock).not.toHaveBeenCalled();
}),
);

it.effect("does not prime a Live Activity after sign-out during the preference read", () =>
Effect.gen(function* () {
let markRequested = () => {};
const requested = new Promise<void>((resolve) => {
markRequested = resolve;
});
let resolvePreferences = (_preferences: Preferences) => {};
vi.mocked(loadPreferences).mockImplementationOnce(() => {
markRequested();
return new Promise((resolve) => {
resolvePreferences = resolve;
});
});
const relay = fakeRelay(() => Effect.succeed({ aggregate }));
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a");

const refresh = yield* Effect.forkChild(
refreshActiveLiveActivityRemoteRegistration().pipe(Effect.provide(relay)),
);
yield* Effect.promise(() => requested);
setAgentAwarenessRelayTokenProvider(null);
resolvePreferences({ liveActivitiesEnabled: true } as Preferences);
yield* Fiber.join(refresh);

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