Skip to content

Commit baaeda3

Browse files
fix: avoid stale Live Activities when publishing is disabled (#6325)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7e01d33 commit baaeda3

10 files changed

Lines changed: 236 additions & 7 deletions

File tree

apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ import {
2020
clearAgentAwarenessRegistrationRecord,
2121
loadAgentAwarenessRegistrationRecord,
2222
loadOrCreateAgentAwarenessDeviceId,
23+
loadPreferences,
2324
saveAgentAwarenessRegistrationRecord,
2425
} from "../../persistence/imperative";
26+
import type { Preferences } from "../../persistence/mobile-preferences";
2527
import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
2628
import {
2729
AgentAwarenessOperationError,
2830
__resetAgentAwarenessRemoteRegistrationForTest,
31+
armAgentAwarenessLiveActivityForLocalWork,
2932
getAgentAwarenessRegistrationStatus,
3033
mergeAgentAwarenessRegistrationPreferences,
3134
refreshActiveLiveActivityRemoteRegistration,
@@ -43,6 +46,13 @@ import * as Notifications from "expo-notifications";
4346
const secureStore = vi.hoisted(() => new Map<string, string>());
4447
const widgetMocks = vi.hoisted(() => ({
4548
getInstances: vi.fn(() => []),
49+
start: vi.fn(() => ({})),
50+
}));
51+
const environmentConfigsMock = vi.hoisted(() => ({
52+
configs: new Map<
53+
string,
54+
{ environment: { capabilities: { agentActivityPublishing?: boolean } } }
55+
>(),
4656
}));
4757
const backgroundRuntime = vi.hoisted(() => ({
4858
pending: [] as Array<{
@@ -77,9 +87,22 @@ vi.mock("expo-widgets", () => ({
7787
vi.mock("../../widgets/AgentActivity", () => ({
7888
default: {
7989
getInstances: widgetMocks.getInstances,
90+
start: widgetMocks.start,
8091
},
8192
}));
8293

94+
// The state modules pull the whole connection stack (and native expo modules)
95+
// into the import graph; the arming gate only needs the configs map.
96+
vi.mock("../../state/atom-registry", () => ({
97+
appAtomRegistry: {
98+
get: () => environmentConfigsMock.configs,
99+
},
100+
}));
101+
102+
vi.mock("../../state/server", () => ({
103+
environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"),
104+
}));
105+
83106
vi.mock("expo-notifications", () => ({
84107
addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })),
85108
getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })),
@@ -227,6 +250,8 @@ describe("makeRelayDeviceRegistrationRequest", () => {
227250
vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
228251
widgetMocks.getInstances.mockReset();
229252
widgetMocks.getInstances.mockReturnValue([]);
253+
widgetMocks.start.mockClear();
254+
environmentConfigsMock.configs.clear();
230255
});
231256

232257
it("preserves disabled Live Activity preferences in relay registrations", () => {
@@ -856,4 +881,55 @@ describe("makeRelayDeviceRegistrationRequest", () => {
856881
}).pipe(Effect.provide(relayTestLayer));
857882
},
858883
);
884+
885+
it("skips the Live Activity seed when the environment reports publishing disabled", async () => {
886+
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
887+
vi.mocked(loadPreferences).mockResolvedValueOnce({
888+
liveActivitiesEnabled: true,
889+
} as Preferences);
890+
environmentConfigsMock.configs.set("env-1", {
891+
environment: { capabilities: { agentActivityPublishing: false } },
892+
});
893+
894+
armAgentAwarenessLiveActivityForLocalWork({
895+
environmentId: "env-1" as EnvironmentId,
896+
threadTitle: "Fix the flaky test",
897+
projectTitle: "t3code",
898+
});
899+
await new Promise((resolve) => setTimeout(resolve, 0));
900+
901+
expect(widgetMocks.start).not.toHaveBeenCalled();
902+
});
903+
904+
it("seeds the Live Activity for publishing and pre-capability environments", async () => {
905+
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
906+
environmentConfigsMock.configs.set("env-publishing", {
907+
environment: { capabilities: { agentActivityPublishing: true } },
908+
});
909+
910+
vi.mocked(loadPreferences).mockResolvedValueOnce({
911+
liveActivitiesEnabled: true,
912+
} as Preferences);
913+
armAgentAwarenessLiveActivityForLocalWork({
914+
environmentId: "env-publishing" as EnvironmentId,
915+
threadTitle: "Fix the flaky test",
916+
projectTitle: "t3code",
917+
});
918+
await new Promise((resolve) => setTimeout(resolve, 0));
919+
expect(widgetMocks.start).toHaveBeenCalledTimes(1);
920+
921+
// An environment without the capability may run an older server that
922+
// still publishes; only an explicit false skips the seed.
923+
widgetMocks.start.mockClear();
924+
vi.mocked(loadPreferences).mockResolvedValueOnce({
925+
liveActivitiesEnabled: true,
926+
} as Preferences);
927+
armAgentAwarenessLiveActivityForLocalWork({
928+
environmentId: "env-pre-capability" as EnvironmentId,
929+
threadTitle: "Fix the flaky test",
930+
projectTitle: "t3code",
931+
});
932+
await new Promise((resolve) => setTimeout(resolve, 0));
933+
expect(widgetMocks.start).toHaveBeenCalledTimes(1);
934+
});
859935
});

apps/mobile/src/features/agent-awareness/remoteRegistration.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020

2121
import type { SavedRemoteConnection } from "../../lib/connection";
2222
import { runtime } from "../../lib/runtime";
23+
import { appAtomRegistry } from "../../state/atom-registry";
24+
import { environmentServerConfigsAtom } from "../../state/server";
2325
import type { Preferences } from "../../persistence/mobile-preferences";
2426
import {
2527
clearAgentAwarenessRegistrationRecord,
@@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: {
448450
});
449451
}
450452

453+
// The environment descriptor advertises whether agent-activity publishes
454+
// currently leave that server (`capabilities.agentActivityPublishing`). Only
455+
// an explicit false skips the seed card: older servers omit the capability
456+
// but may still publish.
457+
function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean {
458+
return (
459+
appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
460+
.agentActivityPublishing !== false
461+
);
462+
}
463+
451464
// Arms the lock-screen card the moment the user starts agent work from this
452465
// phone, while the app is still foregrounded and the fresh activity's token
453466
// can be registered immediately. The seeded row is a best-effort placeholder;
454467
// the relay's registration replay repaints it with the authoritative
455-
// aggregate within seconds. No-ops when a card is already armed.
468+
// aggregate within seconds. No-ops when a card is already armed, and skips
469+
// environments that report publishing disabled — the seed would sit on
470+
// "Connecting" forever with no update ever arriving to repaint or end it.
456471
export function armAgentAwarenessLiveActivityForLocalWork(input: {
472+
readonly environmentId: EnvironmentId;
457473
readonly threadTitle: string;
458474
readonly projectTitle: string;
459475
}): void {
460476
if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
461477
return;
462478
}
479+
if (!environmentPublishesAgentActivity(input.environmentId)) {
480+
logRegistrationDebug("live activity arming skipped; environment does not publish", {
481+
environmentId: input.environmentId,
482+
});
483+
return;
484+
}
463485
void loadPreferences()
464486
.catch(() => null)
465487
.then((preferences) => {

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,7 @@ export function NewTaskDraftScreen(props: {
709709
// -only Activity start. If creation fails, the token registration's replay
710710
// finds no work and ends the card within seconds.
711711
armAgentAwarenessLiveActivityForLocalWork({
712+
environmentId: selectedProject.environmentId,
712713
threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
713714
projectTitle: selectedProject.title,
714715
});

apps/mobile/src/features/threads/ThreadComposer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
541541
// after the send so its preference read and native Activity start don't
542542
// contend with the queued-message feedback on the tap frame.
543543
armAgentAwarenessLiveActivityForLocalWork({
544+
environmentId: props.environmentId,
544545
threadTitle: props.selectedThread.title,
545546
projectTitle: props.environmentLabel ?? "T3 Code",
546547
});

apps/server/src/cli/connect.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import * as CliState from "../cloud/CliState.ts";
3636
import * as CliTokenManager from "../cloud/CliTokenManager.ts";
3737
import {
3838
CLOUD_LINKED_USER_ID,
39+
isAgentActivityPublishingEnabledValue,
3940
PUBLISH_AGENT_ACTIVITY_SECRET,
4041
RELAY_URL_SECRET,
4142
} from "../cloud/config.ts";
@@ -142,7 +143,7 @@ function stringToBytes(value: string): Uint8Array {
142143
}
143144

144145
export function isPublishAgentActivityEnabledValue(value: string | null): boolean {
145-
return value === "true";
146+
return isAgentActivityPublishingEnabledValue(value);
146147
}
147148

148149
interface CloudCliStatus {
@@ -447,7 +448,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E
447448
),
448449
RelayClient.layerCloudflared({ baseDir: config.baseDir }),
449450
EnvironmentAuth.runtimeLayer,
450-
ServerEnvironment.layer,
451+
ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
451452
bootServiceLayer(config),
452453
headlessRelayClientTracingLayer,
453454
).pipe(

apps/server/src/cloud/config.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2+
import * as Effect from "effect/Effect";
3+
import * as Option from "effect/Option";
24
import * as Schema from "effect/Schema";
35

6+
import type * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7+
48
export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
59
export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
610
export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id";
@@ -16,3 +20,39 @@ export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
1620
export const decodeRuntimeConfig = Schema.decodeUnknownOption(
1721
Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
1822
);
23+
24+
export function isAgentActivityPublishingEnabledValue(value: string | null): boolean {
25+
return value === "true";
26+
}
27+
28+
/** Whether agent-activity publishes currently leave this environment: the
29+
publish opt-in secret is enabled and the relay link credentials exist.
30+
Mirrors the per-publish gate in AgentAwarenessRelay, so the descriptor
31+
capability never advertises publishing that the publisher would skip. */
32+
export const readAgentActivityPublishingActive = (
33+
secrets: ServerSecretStore.ServerSecretStore["Service"],
34+
): Effect.Effect<boolean> =>
35+
Effect.gen(function* () {
36+
const readSecretString = (name: string) =>
37+
secrets
38+
.get(name)
39+
.pipe(
40+
Effect.map((bytes) =>
41+
Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null,
42+
),
43+
);
44+
const [enabled, url, environmentCredential] = yield* Effect.all([
45+
readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET),
46+
readSecretString(RELAY_URL_SECRET),
47+
readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
48+
]);
49+
// Empty strings are as unconfigured as missing files: the publisher's
50+
// truthiness gate skips them, so the capability must too.
51+
return (
52+
isAgentActivityPublishingEnabledValue(enabled) &&
53+
url !== null &&
54+
url !== "" &&
55+
environmentCredential !== null &&
56+
environmentCredential !== ""
57+
);
58+
}).pipe(Effect.orElseSucceed(() => false));

apps/server/src/environment/ServerEnvironment.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@ import { expect, it } from "@effect/vitest";
33
import * as Effect from "effect/Effect";
44
import * as FileSystem from "effect/FileSystem";
55
import * as Layer from "effect/Layer";
6+
import * as Option from "effect/Option";
67
import * as PlatformError from "effect/PlatformError";
78
import * as Schema from "effect/Schema";
89

10+
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
11+
import {
12+
PUBLISH_AGENT_ACTIVITY_SECRET,
13+
RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
14+
RELAY_URL_SECRET,
15+
} from "../cloud/config.ts";
916
import * as ServerConfig from "../config.ts";
1017
import * as ServerEnvironment from "./ServerEnvironment.ts";
1118

@@ -14,7 +21,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is(
1421
);
1522

1623
const makeServerEnvironmentLayer = (baseDir: string) =>
17-
ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
24+
ServerEnvironment.layer.pipe(
25+
Layer.provide(ServerSecretStore.layer),
26+
Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)),
27+
);
28+
29+
const emptySecretStoreLayer = Layer.succeed(
30+
ServerSecretStore.ServerSecretStore,
31+
ServerSecretStore.ServerSecretStore.of({
32+
get: () => Effect.succeed(Option.none()),
33+
set: () => Effect.void,
34+
create: () => Effect.void,
35+
getOrCreateRandom: () => Effect.succeed(new Uint8Array()),
36+
remove: () => Effect.void,
37+
}),
38+
);
1839

1940
const makeServerConfig = Effect.fn(function* (baseDir: string) {
2041
const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined);
@@ -71,6 +92,53 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
7192
expect(second.capabilities.connectionProbe).toBe(true);
7293
expect(second.capabilities.pullRequests).toBe(true);
7394
expect(second.capabilities.threadTitleRegeneration).toBe(true);
95+
expect(second.capabilities.agentActivityPublishing).toBe(false);
96+
}),
97+
);
98+
99+
it.effect("reports agent activity publishing from the current secret state", () =>
100+
Effect.gen(function* () {
101+
const fileSystem = yield* FileSystem.FileSystem;
102+
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
103+
prefix: "t3-server-environment-publish-test-",
104+
});
105+
const testLayer = Layer.mergeAll(
106+
ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)),
107+
ServerSecretStore.layer,
108+
).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
109+
110+
yield* Effect.gen(function* () {
111+
const secrets = yield* ServerSecretStore.ServerSecretStore;
112+
const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
113+
const encode = (value: string) => new TextEncoder().encode(value);
114+
115+
const unlinked = yield* serverEnvironment.getDescriptor;
116+
expect(unlinked.capabilities.agentActivityPublishing).toBe(false);
117+
118+
// The opt-in alone is not enough: without relay link credentials no
119+
// publish would leave this environment.
120+
yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("true"));
121+
const withoutLink = yield* serverEnvironment.getDescriptor;
122+
expect(withoutLink.capabilities.agentActivityPublishing).toBe(false);
123+
124+
// Empty credentials are as unconfigured as missing ones: the
125+
// publisher's truthiness gate skips them, so the capability must not
126+
// advertise publishing.
127+
yield* secrets.set(RELAY_URL_SECRET, encode(""));
128+
yield* secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential"));
129+
const emptyUrl = yield* serverEnvironment.getDescriptor;
130+
expect(emptyUrl.capabilities.agentActivityPublishing).toBe(false);
131+
132+
yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example"));
133+
const linked = yield* serverEnvironment.getDescriptor;
134+
expect(linked.capabilities.agentActivityPublishing).toBe(true);
135+
136+
// The toggle changes at runtime, so the same service instance must
137+
// reflect a flip without a restart.
138+
yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false"));
139+
const disabled = yield* serverEnvironment.getDescriptor;
140+
expect(disabled.capabilities.agentActivityPublishing).toBe(false);
141+
}).pipe(Effect.provide(testLayer));
74142
}),
75143
);
76144

@@ -113,6 +181,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
113181
}).pipe(
114182
Effect.provide(
115183
ServerEnvironment.layer.pipe(
184+
Layer.provide(emptySecretStoreLayer),
116185
Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)),
117186
),
118187
),

0 commit comments

Comments
 (0)