From 2e3a6eb00e767ccc3aedec1a8204948ac60696ef Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 27 Oct 2026 22:54:07 -0400 Subject: [PATCH 01/18] fix(mobile): keep Live Activity status aligned with the inbox --- .../AgentActivityPresentation.kt | 10 +- .../remoteRegistration.test.ts | 63 +++ .../agent-awareness/remoteRegistration.ts | 17 +- apps/mobile/src/widgets/AgentActivity.tsx | 393 ++++++++++++------ .../src/relay/AgentAwarenessRelay.test.ts | 134 +++++- apps/server/src/relay/AgentAwarenessRelay.ts | 96 ++--- .../AgentActivityPublisher.test.ts | 85 +++- .../agentActivity/AgentActivityPublisher.ts | 17 +- .../src/agentActivity/AgentActivityRows.ts | 1 + .../src/agentActivity/ApnsClient.test.ts | 14 + infra/relay/src/agentActivity/ApnsClient.ts | 7 +- .../src/agentActivity/ApnsDeliveries.test.ts | 23 + .../relay/src/agentActivity/ApnsDeliveries.ts | 36 +- .../src/agentActivity/FcmDeliveries.test.ts | 2 +- .../relay/src/agentActivity/FcmDeliveries.ts | 6 +- .../agentActivity/agentActivityAggregate.ts | 102 ++--- .../src/agentActivity/agentActivityAlerts.ts | 13 +- .../agentActivity/agentActivityPolicy.test.ts | 69 +++ infra/relay/src/agentActivity/fcmPayloads.ts | 71 +++- .../EnvironmentPublishSignatures.test.ts | 16 + .../EnvironmentPublishSignatures.ts | 1 + infra/relay/src/http/Api.ts | 1 + packages/contracts/src/relay.ts | 12 + packages/shared/src/agentAwareness.test.ts | 73 +++- packages/shared/src/agentAwareness.ts | 75 +++- patches/@expo__ui@58.0.20.patch | 34 ++ pnpm-lock.yaml | 8 +- 27 files changed, 1037 insertions(+), 342 deletions(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt index 0adeb3fc3c42c..6462b92d4494d 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt @@ -94,9 +94,11 @@ internal class ActivityPresentation(data: Map, private val activ ?: rows.count { ActivityPhase.forStatus(it.status)?.finished != true } private val attentionCount = data["activity_attention_count"]?.toIntOrNull()?.coerceAtLeast(0) ?: rows.count { ActivityPhase.forStatus(it.status)?.needsUser == true } + private val workingCount = data["activity_working_count"]?.toIntOrNull()?.coerceAtLeast(0) + ?: (activeCount - attentionCount).coerceAtLeast(0) private val failedCount = rows.count { it.status == ActivityPhase.FAILED.status } - val threadCount = - activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true } + val threadCount = data["activity_total_count"]?.toIntOrNull()?.coerceAtLeast(0) + ?: (activeCount + rows.count { ActivityPhase.forStatus(it.status)?.finished == true }) private val singleProject = rows.map { it.project }.distinct().size == 1 private val legacyBody = (0..4).mapNotNull { data["activity_line_$it"]?.take(300) } .takeIf { it.isNotEmpty() }?.joinToString("\n") @@ -109,7 +111,7 @@ internal class ActivityPresentation(data: Map, private val activ attentionCount == 1 -> "1 needs you" attentionCount > 1 -> "$attentionCount need you" activeCount > 0 && failedCount > 0 -> "$failedCount failed" - activeCount > 0 -> "$activeCount working" + workingCount > 0 -> "$workingCount working" failedCount > 0 -> "Finished, $failedCount failed" else -> "All finished" } @@ -137,7 +139,7 @@ internal class ActivityPresentation(data: Map, private val activ builder.setSubText( listOfNotNull( hero!!.project.takeIf { singleProject && it.isNotBlank() }, - if (activeCount > 0) "$activeCount active" else "$threadCount threads" + if (activeCount > 0) "$workingCount working" else "$threadCount threads" ).joinToString(" · ") ) } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d7e340d886ff2..5f30a3adfabd0 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -20,6 +20,7 @@ import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, + loadPreferences, saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; @@ -247,10 +248,72 @@ describe("makeRelayDeviceRegistrationRequest", () => { widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockClear(); + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: false }); widgetMocks.dismissEnded.mockReset(); environmentConfigsMock.configs.clear(); }); + it.effect.each([true, false])("primes unread Done without live work: unread=%s", (unread) => { + const aggregate = { + title: "T3 Code", + subtitle: "Agent work completed", + activeCount: 0, + workingCount: 0, + attentionCount: 0, + unreadCount: unread ? 1 : 0, + pendingCount: unread ? 1 : 0, + totalCount: 1, + updatedAt: "2026-10-27T12:00:00.000Z", + activities: [ + { + environmentId: "env", + threadId: "thread", + projectTitle: "Project", + threadTitle: "Completed task", + modelTitle: "Test", + phase: "completed", + unread, + status: "Done", + updatedAt: "2026-10-27T12:00:00.000Z", + completedAt: "2026-10-27T12:00:00.000Z", + deepLink: "/", + }, + ], + }; + vi.mocked(loadPreferences).mockResolvedValue({ liveActivitiesEnabled: true }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/v1/client/dpop-token")) + return Response.json({ + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + if (url.endsWith("/v1/mobile/agent-activity")) return Response.json({ aggregate }); + return Response.json({ ok: true }); + }); + widgetMocks.start.mockReturnValue({ + getId: () => "activity-1", + getPushToken: () => Promise.resolve(null), + addPushTokenListener: vi.fn(), + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token"), "user-a"); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + if (unread) { + expect(widgetMocks.start).toHaveBeenCalledWith(aggregate, expect.any(Date)); + } else { + expect(widgetMocks.start).not.toHaveBeenCalled(); + } + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + it("disables push features in Personal Team relay registrations", () => { Constants.expoConfig!.extra = { iosPersonalTeamBuild: true }; diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index fc24d180a3a04..17399df818441 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -980,20 +980,13 @@ const refreshActiveLiveActivityRemoteRegistration = Effect.fn( }).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>)); if (armedMeanwhile.length > 0) { activities = [...armedMeanwhile]; - } else if (snapshot?.aggregate && snapshot.aggregate.activeCount > 0) { + } else if ( + snapshot?.aggregate && + (snapshot.aggregate.pendingCount ?? snapshot.aggregate.activeCount) > 0 + ) { const aggregate = snapshot.aggregate; const primed = yield* Effect.try({ - try: () => - startAgentLiveActivity( - { - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, - }, - liveActivityStaleDate(), - ), + try: () => startAgentLiveActivity(aggregate, liveActivityStaleDate()), catch: (cause) => new AgentAwarenessOperationError({ operation: "prime-live-activity", diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 609beceac0cdf..df2c00d443f77 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -4,12 +4,14 @@ import { accessibilityElement, accessibilityLabel, activityBackgroundTint, + aspectRatio, font, foregroundStyle, frame, layoutPriority, lineLimit, minimumScaleFactor, + multilineTextAlignment, padding, resizable, widgetURL, @@ -38,6 +40,8 @@ export interface AgentActivityRowProps { readonly threadTitle: string; readonly modelTitle: string; readonly phase: AgentActivityPhase; + readonly unread?: boolean; + readonly completedAt?: string; readonly status: string; readonly updatedAt: string; readonly deepLink: string; @@ -47,6 +51,11 @@ export interface AgentActivityProps { readonly title: string; readonly subtitle: string; readonly activeCount: number; + readonly workingCount?: number | null; + readonly attentionCount?: number | null; + readonly totalCount?: number | null; + readonly unreadCount?: number | null; + readonly pendingCount?: number | null; readonly updatedAt: string; readonly activities: ReadonlyArray; } @@ -58,6 +67,46 @@ function AgentActivity( ): LiveActivityLayout { "widget"; + // Placeholder / first-paint entries arrive with empty props. Treat missing + // fields as idle rather than throwing inside the widget JS runtime. + const receivedActivities = Array.isArray(props.activities) ? props.activities : []; + const activeCount = + props.activeCount === null || (typeof props.activeCount === "number" && props.activeCount < 0) + ? null + : typeof props.activeCount === "number" + ? props.activeCount + : 0; + const visibleActiveCount = receivedActivities.filter( + (row) => row.phase !== "completed" && row.phase !== "failed", + ).length; + const countsComplete = activeCount !== null && activeCount === visibleActiveCount; + const workingCount = + typeof props.workingCount === "number" && props.workingCount >= 0 + ? props.workingCount + : countsComplete + ? receivedActivities.filter((row) => row.phase === "running" || row.phase === "starting") + .length + : null; + const attentionCount = + typeof props.attentionCount === "number" && props.attentionCount >= 0 + ? props.attentionCount + : countsComplete + ? receivedActivities.filter( + (row) => row.phase === "waiting_for_input" || row.phase === "waiting_for_approval", + ).length + : null; + const totalCount = + typeof props.totalCount === "number" && props.totalCount >= receivedActivities.length + ? props.totalCount + : null; + const unreadCount = + typeof props.unreadCount === "number" && props.unreadCount >= 0 + ? props.unreadCount + : totalCount === receivedActivities.length + ? receivedActivities.filter((row) => row.phase === "completed" && row.unread !== false) + .length + : null; + // Hierarchical foregrounds inherit system tint and vibrancy. type Foreground = Parameters[0]; const primaryForeground = { type: "hierarchical", style: "primary" } as const; @@ -76,37 +125,46 @@ function AgentActivity( } switch (phase) { case "waiting_for_approval": - return isLightScheme ? "#d97706" : "#fcd34d"; case "waiting_for_input": - return isLightScheme ? "#4f46e5" : "#a5b4fc"; + return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300 case "failed": - return isLightScheme ? "#dc2626" : "#fca5a5"; + return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300 case "completed": - return isLightScheme ? "#059669" : "#6ee7b7"; + return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300 case "stale": return secondaryForeground; case "starting": case "running": default: - return isLightScheme ? "#0284c7" : "#7dd3fc"; + return isLightScheme ? "#0284c7" : "#7dd3fc"; // sky-600 / sky-300 } }; - const phasePriority = (phase: AgentActivityPhase): number => { - if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; - if (phase === "failed") return 1; - if (phase === "running" || phase === "starting") return 2; - return 3; + const phasePriority = (row: AgentActivityRowProps): number => { + if (row.phase === "waiting_for_approval" || row.phase === "waiting_for_input") return 0; + if (row.phase === "failed") return 1; + if (row.phase === "completed" && row.unread !== false) return 2; + if (row.phase === "running" || row.phase === "starting") return 3; + return 4; }; - // Mark in-flight rows stale after expiry so they cannot claim agents still work. - const activities: ReadonlyArray = environment.isStale - ? props.activities.map((row) => + // Past a Live Activity's stale date the system stops vouching for the + // content, so every in-flight row degrades to "stale" rather than claiming + // an agent is still working. Terminal phases keep their own state. + const isSystemStale = "isStale" in environment && environment.isStale === true; + const activities: ReadonlyArray = isSystemStale + ? receivedActivities.map((row) => row.phase === "completed" || row.phase === "failed" ? row : { ...row, phase: "stale", status: "Out of date" }, ) - : props.activities; - const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase)); + : receivedActivities; + const ordered = [...activities].sort( + (a, b) => + phasePriority(a) - phasePriority(b) || + b.updatedAt.localeCompare(a.updatedAt) || + a.environmentId.localeCompare(b.environmentId) || + a.threadId.localeCompare(b.threadId), + ); const row0 = ordered[0]; const row1 = ordered[1]; const row2 = ordered[2]; @@ -119,34 +177,57 @@ function AgentActivity( const attentionRow = attentionRows[0]; const failedRow = activities.find((row) => row.phase === "failed"); const heroRow = attentionRow ?? failedRow ?? row0; - const tint = phaseTint(heroRow?.phase); + const tint = + heroRow?.phase === "completed" && heroRow.unread === false + ? secondaryForeground + : phaseTint(heroRow?.phase); + // Headline count leans on the accent when a human is actually blocked. const headerTint = attentionRow ? phaseTint(attentionRow.phase) : failedRow ? phaseTint(failedRow.phase) : tint; - const allDone = props.activeCount === 0; - const doneLabel = failedRow ? "Failed" : "Done"; - const outcomeLabel = failedRow ? "Agent work failed" : "Agent work completed"; + // Derive terminal outcomes from all rows so a failure dominates a newer success in every presentation. + const allDone = + typeof props.pendingCount === "number" && props.pendingCount >= 0 + ? props.pendingCount === 0 + : activeCount === 0 && unreadCount === 0; + const hasRows = activities.length > 0; + const doneLabel = failedRow ? "Failed" : hasRows ? "Done" : "Idle"; + const outcomeLabel = failedRow + ? "Agent work failed" + : hasRows + ? "All caught up" + : "No active agents"; - const agentWord = props.activeCount === 1 ? "agent" : "agents"; - const agentsLabel = allDone + const countLabel = allDone ? outcomeLabel - : environment.isStale + : isSystemStale ? "Agent status out of date" - : `${props.activeCount} active ${agentWord}`; + : workingCount === null + ? "Working count unavailable" + : workingCount > 0 + ? `${workingCount} working` + : ""; + const agentsLabel = countLabel; const attentionSuffix = - attentionRows.length > 0 - ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` + attentionCount !== null && attentionCount > 0 + ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} you` : ""; - const activeLabel = allDone + const doneSuffix = unreadCount !== null && unreadCount > 0 ? `${unreadCount} done` : ""; + const currentLabel = allDone ? doneLabel - : environment.isStale + : isSystemStale ? "Out of date" - : `${props.activeCount} active`; - const showCompactGlyph = !attentionRow && !failedRow && !allDone && !environment.isStale; - const summary = attentionSuffix || activeLabel; + : workingCount === null + ? "Count unavailable" + : workingCount > 0 + ? `${workingCount} working` + : ""; + const activeLabel = currentLabel; + const showCompactGlyph = !attentionRow && !failedRow && !doneSuffix && !allDone && !isSystemStale; + const summary = attentionSuffix || doneSuffix || activeLabel; // Widget taps target the containing app, so its production scheme is safe in every build. const deepLinkRow = attentionRow ?? row0; @@ -154,8 +235,11 @@ function AgentActivity( deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//") ? `t3code://${deepLinkRow.deepLink.slice(1)}` : null; + const linkModifiers = deepLink ? [widgetURL(deepLink)] : []; + // A scannable status glyph per phase — reads faster than colored words and + // ties the compact / expanded / banner / watch presentations together. type SFName = NonNullable["systemName"]>; const phaseSymbol = (phase: AgentActivityPhase): SFName => { switch (phase) { @@ -179,7 +263,8 @@ function AgentActivity( }; // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to - // the image; size + tint them through a container the resizable symbol fills. + // the image; size + tint them through a container. Preserve the symbol's + // intrinsic aspect ratio when the resizable image fills that frame. const renderGlyph = ( systemName: SFName, size: number, @@ -189,16 +274,55 @@ function AgentActivity( - + ); + const isRead = (row: AgentActivityRowProps) => row.phase === "completed" && row.unread === false; + const isEmphasized = (row: AgentActivityRowProps) => + row.phase === "waiting_for_input" || + row.phase === "waiting_for_approval" || + (row.phase === "completed" && row.unread !== false); + const renderRowStatus = (row: AgentActivityRowProps) => + isRead(row) ? ( + + ) : ( + + {row.status} + + ); + + // Single-line rows fit the expanded island height budget; protect project and status from truncation. const renderCompactRow = (row: AgentActivityRowProps) => ( + {row.phase === "completed" && row.unread !== false ? ( + ● + ) : null} @@ -209,22 +333,11 @@ function AgentActivity( {row.projectTitle} - - {row.status} - + {renderRowStatus(row)} ); - // Image views only honor `resizable` directly (frame/foregroundStyle are - // dropped), so we size it via a container frame the resizable image fills and - // tint it through the container's foreground style, which the template image - // inherits. + // Widget Image ignores frame/foregroundStyle; size and tint its resizable template through a 3:2 container. const renderLogo = (height: number, color: Foreground, modifiers: typeof linkModifiers = []) => ( ); - return { - banner: ( - - - - {renderLogo(13, primaryForeground)} - - - - - - {agentsLabel} - - {attentionSuffix ? ( - · - ) : null} - {attentionSuffix ? ( - - {attentionSuffix} - - ) : null} - - - - {row0 ? renderCompactRow(row0) : null} - {row1 ? renderCompactRow(row1) : null} - {row2 ? renderCompactRow(row2) : null} - {row3 ? renderCompactRow(row3) : null} - {row4 ? renderCompactRow(row4) : null} - - ), - bannerSmall: ( - + // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family) + // and lock-screen accessory widgets. + const renderCompactLayout = () => ( + + + {renderLogo(14, primaryForeground)} + + {activeCount === null ? activeLabel : summary} + + + + {row0 ? ( - {renderLogo(14, primaryForeground)} - {attentionRows.length > 0 ? summary : activeLabel} + {row0.threadTitle} + {renderRowStatus(row0)} - {row0 ? ( - + ) : null} + + ); + + const banner = ( + + {/* Keep the status centered independently of the logo. */} + + + {renderLogo(13, primaryForeground)} + + + + + + {agentsLabel} + + {attentionSuffix && agentsLabel ? ( + · + ) : null} + {attentionSuffix ? ( - {row0.threadTitle} + {attentionSuffix} - - - {row0.status} + ) : null} + {doneSuffix ? ( + + {`${agentsLabel || attentionSuffix ? "· " : ""}${doneSuffix}`} - - ) : null} - - ), + ) : null} + + + + {row0 ? renderCompactRow(row0) : null} + {row1 ? renderCompactRow(row1) : null} + {row2 ? renderCompactRow(row2) : null} + {row3 ? renderCompactRow(row3) : null} + {totalCount !== null && totalCount > 5 ? ( + + {`${totalCount - 4} more threads · Open T3`} + + ) : row4 ? ( + renderCompactRow(row4) + ) : null} + + ); + + return { + banner, + bannerSmall: renderCompactLayout(), compactLeading: renderLogo(14, tint, linkModifiers), // While agents are only working, stay quiet: the thread list's Working // glyph marks the activity without a count. Labels appear only for @@ -350,7 +495,7 @@ function AgentActivity( : "Input" : failedRow ? "Failed" - : activeLabel} + : doneSuffix || activeLabel} ), minimal: heroRow @@ -360,13 +505,23 @@ function AgentActivity( {renderLogo(15, tint)} - {allDone ? doneLabel : `${props.activeCount}`} + {isSystemStale ? "Out of date" : allDone ? doneLabel : summary} ), + // No center content: the phase glyphs + statuses in expandedBottom already + // carry the attention signal, and the expanded island's height budget is + // tight enough that a summary line there pushed the third row off. expandedCenter: null, + // No trailing content: a timestamp is glanceable-lock-screen info, not + // useful in a view the user is actively holding open — and the trailing + // region hugs the island's corner radius, which clipped it anyway. expandedTrailing: null, expandedBottom: ( + // Vertical padding only: the expanded region provides its own horizontal + // content margins, so `all` padding double-indented the rows. + // Horizontal padding keeps both edges clear of the island's corner + // curvature (right edge clipped status labels; titles hugged the left). = {}): Orchestrati }; } -const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) }); +const PublishPayload = Schema.Struct({ + state: Schema.NullOr(RelayAgentActivityState), + replay: Schema.optional(Schema.Boolean), +}); const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload)); const unused = () => Effect.die("Unexpected test dependency call"); describe("startup agent activity", () => { - it("publishes active work and only terminal runs completed after startup", () => { + it.effect("backfills pre-start completions silently", () => + Effect.gen(function* () { + const { relay, publications } = yield* makeTestRelay({ + readShell: () => + Effect.succeed( + shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("1969-12-31T00:00:00.000Z"), + }), + ), + }); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + assert.equal(publications[0]?.replay, true); + }), + ); + it("publishes active work and older unsettled completions after startup", () => { const startedAt = DateTime.toEpochMillis(DateTime.makeUnsafe(NOW)); const oldCompleted = ThreadId.make("old-completed"); const newCompleted = ThreadId.make("new-completed"); @@ -133,7 +153,7 @@ describe("startup agent activity", () => { shell({ id: ThreadId.make("missing-project"), projectId: ProjectId.make("missing") }), ], }); - assert.deepStrictEqual(ids, [THREAD_ID, newCompleted, newFailed]); + assert.deepStrictEqual(ids, [THREAD_ID, oldCompleted, newCompleted, oldFailed, newFailed]); }); }); @@ -229,6 +249,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( const publications: Array<{ readonly url: string; readonly authorization: string | null; + readonly replay?: boolean; readonly state: RelayAgentActivityState | null; }> = []; const fetch: typeof globalThis.fetch = Object.assign( @@ -247,6 +268,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( url: String(input), authorization: new Headers(init?.headers).get("authorization"), state: payload.state, + replay: payload.replay ?? false, }); return Promise.resolve( options.respond?.(publications.length) ?? Response.json({ ok: true, deliveries: [] }), @@ -337,6 +359,64 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("publishes secure-input transitions without sending the secret prompt", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const pulls = yield* Queue.unbounded(); + const { relay, currentShell, publications } = yield* makeTestRelay({ + domainEvents: Stream.fromEffectRepeat( + Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), + ), + }); + yield* relay.start(); + yield* Queue.take(pulls); + yield* relay.publishThread(THREAD_ID); + const now = yield* DateTime.now; + const request = { + id: RuntimeRequestId.make("secret-input"), + kind: "user_input" as const, + createdAt: now, + blocking: true, + }; + for (const waiting of [true, false]) { + yield* Ref.set(currentShell, shell({ pendingRuntimeRequest: waiting ? request : null })); + yield* Queue.offer(events, { + id: EventId.make(`secret-event-${waiting}`), + type: "turn-item.updated", + threadId: THREAD_ID, + runId: RunId.make("secret-run"), + occurredAt: now, + payload: { + id: TurnItemId.make("secret-input"), + type: "secret_request", + threadId: THREAD_ID, + runId: RunId.make("secret-run"), + nodeId: NodeId.make("secret-node"), + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + title: null, + startedAt: now, + completedAt: waiting ? null : now, + updatedAt: now, + status: waiting ? "waiting" : "completed", + label: "PRIVATE_LABEL", + reason: "PRIVATE_REASON", + secretStatus: waiting ? "pending" : "saved", + }, + }); + yield* Queue.take(pulls); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, waiting ? "waiting_for_input" : "running"); + } + assert.equal(publications.length, 3); + assert.isFalse(JSON.stringify(publications).includes("PRIVATE_LABEL")); + assert.isFalse(JSON.stringify(publications).includes("PRIVATE_REASON")); + }).pipe(Effect.scoped), + ); + it.effect("deduplicates state and republishes title changes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); @@ -422,6 +502,36 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped), ); + it.effect( + "publishes reading and marking unread immediately without another completion alert", + () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completedAt = DateTime.makeUnsafe("1970-01-01T00:00:01.000Z"); + const completed = shell({ + status: "completed", + latestRunCompletedAt: completedAt, + lastVisitedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.unread, true); + yield* Ref.set(currentShell, { ...completed, lastVisitedAt: completedAt }); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 3); + assert.equal(publications.at(-1)?.state?.unread, false); + assert.equal(publications.at(-1)?.replay, true); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 4); + assert.equal(publications.at(-1)?.state?.unread, true); + assert.equal(publications.at(-1)?.replay, true); + }), + ); + it.effect("holds a completion through a continuation gap, then publishes it once", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); @@ -449,7 +559,7 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("still publishes a completion when the thread settles during the hold", () => + it.effect("removes a thread settled during a completion hold without publishing Done", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); @@ -467,14 +577,14 @@ describe("AgentAwarenessRelay", () => { yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase ?? null), - ["running", "completed"], + ["running", null], ); yield* TestClock.adjust("5 seconds"); yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase ?? null), - ["running", "completed", null], + ["running", null], ); }), ); @@ -496,7 +606,7 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("retries a failed completion even after the thread settles", () => + it.effect("retries with removal instead of a stale completion after settlement", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay({ respond: (attempt) => @@ -520,7 +630,7 @@ describe("AgentAwarenessRelay", () => { yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase ?? null), - ["running", "completed", "completed"], + ["running", "completed", null], ); yield* TestClock.adjust("5 seconds"); @@ -529,7 +639,7 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("keeps settled live work until its awaited monitor finishes", () => + it.effect("removes settled live work while its monitor is still running", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); @@ -542,15 +652,15 @@ describe("AgentAwarenessRelay", () => { yield* relay.publishThread(THREAD_ID); yield* TestClock.adjust("5 seconds"); yield* relay.drain; - assert.equal(publications.length, 1); + assert.equal(publications.length, 2); + assert.equal(publications[1]?.state, null); assert.equal(publications[0]?.state?.phase, "running"); yield* Ref.set(currentShell, { ...held, pendingBackgroundTasks: [] }); yield* relay.publishThread(THREAD_ID); yield* TestClock.adjust("5 seconds"); yield* relay.drain; - assert.equal(publications.length, 2); - assert.equal(publications[1]?.state, null); + assert.isTrue(publications.slice(1).every((publication) => publication.state === null)); }), ); it.effect("publishes a held completion when its background item ends, not on tool output", () => diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index cbe7b35ef409e..c4c18a4833982 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -94,6 +94,11 @@ function shouldPublishAgentAwarenessEvent( case "thread.pull-request-link-updated": case "thread.pull-request-synced": case "thread.settled": + case "thread.unsettled": + case "thread.snoozed": + case "thread.unsnoozed": + case "thread.visited": + case "thread.marked-unread": case "thread.model-selection-updated": case "thread.provider-switched": case "run.created": @@ -102,18 +107,12 @@ function shouldPublishAgentAwarenessEvent( case "subagent.updated": case "provider-thread.updated": return true; - // Unsettling must not re-alert a dismissed result. - case "thread.unsettled": case "thread.guest-share-updated": - case "thread.snoozed": - case "thread.unsnoozed": case "thread.auto-settle-set": case "thread.pinned": case "thread.unpinned": case "thread.pin-reordered": case "thread.active-reordered": - case "thread.visited": - case "thread.marked-unread": case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": case "run.background-work-cancelled": @@ -125,7 +124,11 @@ function shouldPublishAgentAwarenessEvent( case "provider-session.detached": case "provider-turn.updated": case "turn-item.updated": - return isTurnItemPayload(event.payload) && turnItemUpdateCanEndBackgroundWork(event.payload); + return ( + isTurnItemPayload(event.payload) && + (event.payload.type === "secret_request" || + turnItemUpdateCanEndBackgroundWork(event.payload)) + ); case "message.updated": case "plan.updated": case "checkpoint-scope.created": @@ -252,6 +255,7 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { readonly threadId: ThreadId; readonly state: RelayAgentActivityState | null; readonly jti: string; + readonly replay?: boolean; }) { const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { minutes: 5 }); @@ -265,6 +269,7 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { environmentId: input.environmentId as RelayAgentActivityPublishProofPayload["environmentId"], threadId: input.threadId, state: input.state, + ...(input.replay ? { replay: true } : {}), } satisfies RelayAgentActivityPublishProofPayload; return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload }); }); @@ -291,6 +296,7 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { readonly environmentId: EnvironmentId; readonly thread: Option.Option; readonly project: Option.Option; + readonly now: number; }): { readonly projectId: string | null; readonly state: RelayAgentActivityState | null; @@ -317,6 +323,7 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { environmentId: input.environmentId, project: input.project.value, thread: input.thread.value, + now: input.now, }), ), reason: "snapshot", @@ -347,13 +354,9 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { environmentId: input.environmentId, project, thread, + now: input.startedAt, }); - return ( - state !== null && - (state.phase !== "completed" && state.phase !== "failed" - ? true - : terminalWorkSinceStart(thread, input.startedAt)) - ); + return state !== null; }) .map((thread) => thread.id); } @@ -370,7 +373,9 @@ export const make = Effect.gen(function* () { const startedAt = (yield* DateTime.now).epochMilliseconds; const activeSnapshotPublishedRef = yield* Ref.make(false); const catchUpRequests = yield* Queue.dropping(1); - const publishedStateByThreadRef = yield* Ref.make(new Map()); + const publishedStateByThreadRef = yield* Ref.make( + new Map(), + ); const readSecretString = (name: string) => secrets @@ -410,8 +415,6 @@ export const make = Effect.gen(function* () { // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); - // Threads whose confirmation holds a completion, so a thread that settles during the hold still alerts. - const heldCompletions = new Set(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -430,7 +433,6 @@ export const make = Effect.gen(function* () { publishedRelayConfig = undefined; cachedRelayClient = undefined; publishConfirmDeadlines.clear(); - heldCompletions.clear(); yield* Ref.set(publishedStateByThreadRef, new Map()); }); @@ -467,6 +469,7 @@ export const make = Effect.gen(function* () { readonly projectId: string | null; readonly state: RelayAgentActivityState | null; readonly reason: string; + readonly replay?: boolean; }) { if (cachedRelayClient === undefined) { cachedRelayClient = yield* makeRelayClient(relayConfig); @@ -478,6 +481,7 @@ export const make = Effect.gen(function* () { environmentId, threadId, state: input.state, + replay: input.replay ?? false, jti: yield* crypto.randomUUIDv4(), }); @@ -498,6 +502,7 @@ export const make = Effect.gen(function* () { }, payload: { state: input.state, + ...(input.replay ? { replay: true } : {}), proof, }, }) @@ -531,19 +536,23 @@ export const make = Effect.gen(function* () { environmentId, thread, project, + now: (yield* DateTime.now).epochMilliseconds, }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); - if ( + const previous = publishedStateByThread.get(threadId); + const sameCompletion = + previous?.state.phase === snapshot.state?.phase && + previous?.state.completedAt === snapshot.state?.completedAt; + const replay = (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && - !publishedStateByThread.has(threadId) - ) { - if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; - } - if (publishedStateByThread.get(threadId) === publishIdentity) { + (sameCompletion || + (!publishedStateByThread.has(threadId) && + Option.isSome(thread) && + !terminalWorkSinceStart(thread.value, startedAt))); + if (previous?.identity === publishIdentity) { // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); - heldCompletions.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, @@ -556,11 +565,9 @@ export const make = Effect.gen(function* () { // Confirm completion: a run can end moments before its continuation starts, and the relay // alerts on every move into completed. const requiresConfirmation = - (snapshot.state === null && - publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - snapshot.state?.phase === "completed"; + (snapshot.state === null && previous?.identity !== agentAwarenessPublishIdentity(null)) || + (snapshot.state?.phase === "completed" && !replay); if (requiresConfirmation) { - if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -585,37 +592,8 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); - // Settled during the hold, not resumed: alert the current completion, then clear the card - // on a new window. Only settlement may hide it; queued or silent runs also project to null. - const unsettled = - snapshot.state === null && - heldCompletions.has(threadId) && - Option.isSome(thread) && - thread.value.settledOverride === "settled" - ? resolveAgentAwarenessRelayPublishSnapshot({ - environmentId, - thread: Option.some({ ...thread.value, settledOverride: null }), - project, - }) - : null; - if (unsettled?.state?.phase === "completed") { - const completion = unsettled.state; - yield* publishState({ - projectId: unsettled.projectId, - state: completion, - reason: unsettled.reason, - }).pipe(withRelayClientTracing); - heldCompletions.delete(threadId); - publishConfirmDeadlines.set(threadId, nowMs + 5_000); - yield* Ref.update(publishedStateByThreadRef, (publishedStates) => - new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(completion)), - ); - yield* schedulePublishConfirm(threadId); - return; - } } else { publishConfirmDeadlines.delete(threadId); - heldCompletions.delete(threadId); } if (snapshot.reason === "thread-not-found") { @@ -635,15 +613,15 @@ export const make = Effect.gen(function* () { projectId: snapshot.projectId, state: snapshot.state, reason: snapshot.reason, + replay, }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); - heldCompletions.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); if (snapshot.state === null) { nextPublishedStates.delete(threadId); } else { - nextPublishedStates.set(threadId, publishIdentity); + nextPublishedStates.set(threadId, { identity: publishIdentity, state: snapshot.state }); } return nextPublishedStates; }); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 33f1518ec30ae..51710d5c301e0 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -140,6 +140,46 @@ function makeApnsDeliveries( } describe("AgentActivityPublisher", () => { + it.effect("repaints backfilled inbox rows without a notification", () => { + const replays: boolean[] = []; + return Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + yield* publisher.publish({ + environmentId: state.environmentId, + environmentPublicKey: "key", + threadId: state.threadId, + state: { ...state, phase: "completed", retained: true }, + replay: true, + }); + expect(replays).toEqual([true]); + }).pipe( + Effect.provide( + layerPublisher.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ listTargets: () => Effect.succeed([target("ios")]) }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + replays.push(input.replay === true); + return null; + }), + sendPushNotificationForTarget: () => Effect.die("Backfill must not notify"), + }), + ), + ), + ), + ), + ), + ); + }); it.effect("routes Android publication and registration replay to FCM alongside iOS", () => { const android = { ...target("android"), platform: "android" as const, ios_major_version: null }; const ios = target("ios"); @@ -158,7 +198,7 @@ describe("AgentActivityPublisher", () => { deviceId: android.device_id, }); expect(fcmCalls).toEqual([ - { target: android, state }, + { target: android, state, replay: false }, { target: android, state: null, replay: true }, ]); expect(appleDevices).toEqual(["ios"]); @@ -455,8 +495,8 @@ describe("makeAggregateState", () => { expect(aggregate?.activeCount).toBe(1); expect(aggregate?.subtitle).toBe("Agent work in progress"); expect(aggregate?.activities).toMatchObject([ - { threadId: "thread-active", phase: "running" }, { threadId: "thread-done", phase: "completed", status: "Done" }, + { threadId: "thread-active", phase: "running" }, ]); expect(aggregate?.updatedAt).toBe("1970-01-01T00:59:00.000Z"); }); @@ -487,7 +527,44 @@ describe("makeAggregateState", () => { ).toBeNull(); }); - it("gives active agents the display slots before finished ones", () => { + it("keeps older inbox completions and exact counts beyond the row cap", () => { + const oldRows = Array.from({ length: 8 }, (_, index): RelayAgentActivityState => ({ + ...state, + threadId: `old-${index}` as RelayAgentActivityState["threadId"], + phase: "completed", + retained: true, + })); + const aggregate = makeAggregateState({ + activeStates: [ + { ...state, updatedAt: "1970-01-02T00:00:00.000Z" }, + { + ...state, + threadId: "input" as RelayAgentActivityState["threadId"], + phase: "waiting_for_input", + updatedAt: "1970-01-02T00:00:00.000Z", + }, + ...oldRows, + ], + terminalState: null, + nowMs: 24 * hourMs, + }); + expect(aggregate).toMatchObject({ + activeCount: 2, + workingCount: 1, + attentionCount: 1, + totalCount: 10, + }); + expect(aggregate?.activities).toHaveLength(5); + expect(aggregate?.activities.map((row) => row.phase)).toEqual([ + "waiting_for_input", + "completed", + "completed", + "completed", + "completed", + ]); + }); + + it("gives unread completions display slots before working agents", () => { const mkActive = (id: string): RelayAgentActivityState => ({ ...state, threadId: id as RelayAgentActivityState["threadId"], @@ -514,11 +591,11 @@ describe("makeAggregateState", () => { expect(aggregate?.activeCount).toBe(5); expect(aggregate?.activities).toMatchObject([ + { threadId: "thread-done" }, { threadId: "a-1" }, { threadId: "a-2" }, { threadId: "a-3" }, { threadId: "a-4" }, - { threadId: "a-5" }, ]); }); }); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 6ef258acdb6d3..7b9a7a1cb47ab 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -39,6 +39,7 @@ export class AgentActivityPublisher extends Context.Service< readonly environmentPublicKey: string; readonly threadId: string; readonly state: RelayAgentActivityState | null; + readonly replay?: boolean; }) => Effect.Effect; readonly replayForLiveActivityRegistration: (input: { readonly userId: string; @@ -62,6 +63,7 @@ const make = Effect.gen(function* () { readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord; readonly state: RelayAgentActivityState | null; readonly nowMs: number; + readonly replay?: boolean; }) { const activeStates = input.deliveryUser.liveActivitiesEnabled ? yield* rows.listForUser({ userId: input.deliveryUser.userId }) @@ -74,6 +76,7 @@ const make = Effect.gen(function* () { }) : null; const notificationOnlyAggregate = + !input.replay && input.deliveryUser.notificationsEnabled && !input.deliveryUser.liveActivitiesEnabled && input.state !== null @@ -88,7 +91,13 @@ const make = Effect.gen(function* () { targets, Effect.fnUntraced(function* (target) { if (target.platform === "android") { - return [yield* fcmDeliveries.enqueue({ target, state: input.state })]; + return [ + yield* fcmDeliveries.enqueue({ + target, + state: input.state, + replay: input.replay ?? false, + }), + ]; } return yield* Effect.all( [ @@ -96,6 +105,7 @@ const make = Effect.gen(function* () { target, aggregate: liveActivityAggregate, nowMs: input.nowMs, + replay: input.replay ?? false, }), notificationOnlyAggregate === null ? Effect.succeed(null) @@ -166,7 +176,7 @@ const make = Effect.gen(function* () { terminalState: null, nowMs: now.epochMilliseconds, }); - if (aggregate !== null) return; + if (aggregate !== null && aggregate.pendingCount !== 0) return; const targets = yield* liveActivities.listTargets({ userId }); const idleDeviceIds = new Set(candidates.map((target) => target.device_id)); yield* Effect.forEach( @@ -207,7 +217,7 @@ const make = Effect.gen(function* () { "relay.agent_activity.phase": input.state?.phase ?? "deleted", }); if (input.state) { - // Retain terminal rows until cron pruning so completed threads remain visible alongside active ones. + // Inbox rows are removed by settlement or archival, not by elapsed time. yield* rows.upsert({ environmentPublicKey: input.environmentPublicKey, state: input.state, @@ -231,6 +241,7 @@ const make = Effect.gen(function* () { publishForDeliveryUser({ deliveryUser, state: input.state, + replay: input.replay ?? false, nowMs: now.epochMilliseconds, }), { concurrency: 4 }, diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 0faf5b9da358e..18b7dd33a89d7 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -189,6 +189,7 @@ const make = Effect.gen(function* () { .where( and( sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, + sql`COALESCE(${relayAgentActivityRows.stateJson} ->> 'retained', 'false') != 'true'`, lt(relayAgentActivityRows.updatedAt, input.updatedBefore), ), ) diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index fd0d56ede1c2f..66701c3386a6b 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -109,6 +109,20 @@ describe("ApnsClient", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("dismisses a fully read Live Activity immediately", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makeLiveActivityRequest({ + event: "end", + token: "token", + state: { ...state, activeCount: 0, pendingCount: 0 }, + nowEpochSeconds: 123, + nowIso: DateTime.formatIso(now), + }); + expect(request.payload).toMatchObject({ aps: { event: "end", "dismissal-date": 123 } }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("puts remote routing metadata only in Expo's body field", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index a076cca8ef17e..a33b9082fff8f 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -125,7 +125,12 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA ...(input.state ? { "content-state": contentState(input.state) } : {}), ...(input.alert ? liveActivityAlertPayload(input.alert) : {}), "dismissal-date": - timestamp + (input.state ? DISMISS_AFTER_SECONDS : CONTENTLESS_DISMISS_AFTER_SECONDS), + timestamp + + (input.state?.pendingCount === 0 + ? 0 + : input.state + ? DISMISS_AFTER_SECONDS + : CONTENTLESS_DISMISS_AFTER_SECONDS), }, }, }; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index b2445b0ab4c08..b9d4f206cac70 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1110,6 +1110,29 @@ describe("live activity alert decisions", () => { }); describe("fast completion delivery", () => { + it.effect.each([true, false])( + "keeps unread Done live and ends it when read: unread=%s", + (unread) => { + const queuedJobs: SignedApnsDeliveryJob[] = []; + const done = { + ...aggregate, + activeCount: 0, + pendingCount: unread ? 1 : 0, + unreadCount: unread ? 1 : 0, + activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, unread }], + }; + const device = { ...target, last_aggregate_json: JSON.stringify(aggregate) }; + return Effect.gen(function* () { + const d = yield* ApnsDeliveries.ApnsDeliveries; + yield* d.sendForTarget({ target: device, aggregate: done, nowMs: 0 }); + expect(queuedJobs[0]?.payload.kind).toBe( + unread ? "live_activity_update" : "live_activity_end", + ); + if (!unread) expect(queuedJobs[0]?.payload.alert ?? null).toBeNull(); + }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs, currentTargets: [device] }))); + }, + ); + it.effect("keeps a completion alert when work finishes before running delivery", () => { const queuedJobs: SignedApnsDeliveryJob[] = []; const old = { diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 6e0e4bdc6e236..bc1a124fcf488 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -160,7 +160,12 @@ function shouldUpdateLiveActivity(input: { if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { return false; } - if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) { + if ( + input.previousAggregate.activeCount !== input.nextAggregate.activeCount || + input.previousAggregate.unreadCount !== input.nextAggregate.unreadCount || + input.previousAggregate.pendingCount !== input.nextAggregate.pendingCount || + input.previousAggregate.totalCount !== input.nextAggregate.totalCount + ) { return true; } if (aggregateNeedsAttention(input.nextAggregate)) { @@ -228,7 +233,7 @@ function chooseLiveActivityDelivery(input: { if (!input.target.activity_push_token) { return null; } - if (input.aggregate === null) { + if (input.aggregate === null || input.aggregate.pendingCount === 0) { // Registration replay can precede a new thread's first publish; preserve a freshly armed card until state arrives. const armedAtMs = Option.match( input.target.remote_started_at === null @@ -236,13 +241,17 @@ function chooseLiveActivityDelivery(input: { : DateTime.make(input.target.remote_started_at), { onNone: () => null, onSome: (dt) => dt.epochMilliseconds }, ); - if (armedAtMs !== null && input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS) { + if ( + input.aggregate === null && + armedAtMs !== null && + input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS + ) { return null; } return { kind: "live_activity_end", token: input.target.activity_push_token, - aggregate: null, + aggregate: input.aggregate, alert: null, }; } @@ -517,14 +526,14 @@ const make = Effect.gen(function* () { const userHasContentToShow = Effect.fnUntraced(function* (userId: string) { const now = yield* DateTime.now; return yield* activityRows.listForUser({ userId }).pipe( - Effect.map( - (activeStates) => - makeAggregateState({ - activeStates, - terminalState: null, - nowMs: now.epochMilliseconds, - }) !== null, - ), + Effect.map((activeStates) => { + const aggregate = makeAggregateState({ + activeStates, + terminalState: null, + nowMs: now.epochMilliseconds, + }); + return aggregate !== null && aggregate.pendingCount !== 0; + }), // Keep the card on persistence failures; a later sweep can retry. Effect.catch((error) => Effect.logWarning("Content recheck failed; keeping the card", { error }).pipe( @@ -552,6 +561,8 @@ const make = Effect.gen(function* () { (current) => current !== null && current.phase === input.phase && + (!(current.phase === "completed" || current.phase === "failed") || + current.unread !== false) && current.updatedAt === input.updatedAt, ), Effect.catchCause((cause) => @@ -581,6 +592,7 @@ const make = Effect.gen(function* () { return ( current !== undefined && current.phase === row.phase && + current.unread === row.unread && current.updatedAt === row.updatedAt ); }); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 21df615900f30..c6640e530e559 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -241,7 +241,7 @@ describe("Android delivery routing", () => { expect(alerts).toHaveLength(1); expect(alerts[0]?.data).toMatchObject({ alert_title: title, - alert_body: "Fix notifications, Second thread", + alert_body: "Second thread, Fix notifications", alert_path: "/", active, }); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 615840fe27aeb..216217144234f 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -214,7 +214,7 @@ const make = Effect.gen(function* () { let alert: ReturnType = null; // Deletions update the card but must not consume another thread's pending completion alert. let acknowledgeAggregate = job.state !== null || job.replay === true || aggregate === null; - if (job.state && preferences.value.notificationsEnabled) { + if (job.state && !job.replay && preferences.value.notificationsEnabled) { const state = yield* rows.getForUserThread({ userId: job.userId, environmentId: job.state.environmentId, @@ -285,14 +285,14 @@ const make = Effect.gen(function* () { preferences.value.notificationsEnabled && preferences.value.liveActivitiesEnabled ? aggregate : null; - const active = (displayedAggregate?.activeCount ?? 0) > 0; + const active = (displayedAggregate?.pendingCount ?? displayedAggregate?.activeCount ?? 0) > 0; if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; const data: Record = { t3_kind: "agent_activity", device_id: job.deviceId, user_id: job.userId, updated_at: String(now.epochMilliseconds), - ...androidActivityData(displayedAggregate), + ...androidActivityData(displayedAggregate, now.epochMilliseconds), ...alert, }; if (alert) { diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts index 99fdcf71ef90d..000c9d2ab59a3 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -39,22 +39,14 @@ function aggregateRowForState(state: RelayAgentActivityState) { threadTitle: state.threadTitle, modelTitle: state.modelTitle, phase: state.phase, + ...(state.unread === undefined ? {} : { unread: state.unread }), + ...(state.completedAt === undefined ? {} : { completedAt: state.completedAt }), status: statusForPhase(state.phase), updatedAt: state.updatedAt, deepLink: state.deepLink, }; } -function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActivityAggregateState { - return sanitizeAgentActivityAggregateState({ - title: "T3 Code", - subtitle: state.phase === "failed" ? "Agent work failed" : "Agent work completed", - activeCount: 0, - updatedAt: state.updatedAt, - activities: [aggregateRowForState(state)], - }); -} - export const TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS = 15 * 60 * 1_000; function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): boolean { @@ -68,7 +60,7 @@ function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): b if (Number.isNaN(updatedAtMs)) { return false; } - return nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; + return state.retained === true || nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; } export function makeAggregateState(input: { @@ -76,53 +68,61 @@ export function makeAggregateState(input: { readonly terminalState: RelayAgentActivityState | null; readonly nowMs: number; }): RelayAgentActivityAggregateState | null { - const activeStates = input.activeStates.filter( - (state) => !isTerminalPhase(state) && !isExpiredAgentActivityState(state, input.nowMs), + const states = input.activeStates.filter((state) => + isTerminalPhase(state) + ? isRecentTerminalState(state, input.nowMs) + : !isExpiredAgentActivityState(state, input.nowMs), + ); + if (states.length === 0 && input.terminalState !== null) states.push(input.terminalState); + if (states.length === 0) return null; + const activeCount = states.filter((state) => !isTerminalPhase(state)).length; + const unreadCount = states.filter( + (state) => state.phase === "completed" && state.unread !== false, + ).length; + const pendingCount = + activeCount + states.filter((state) => isTerminalPhase(state) && state.unread !== false).length; + const displayedStates = states + .toSorted( + (a, b) => + activityPhasePriority(a.phase, a.unread) - activityPhasePriority(b.phase, b.unread) || + b.updatedAt.localeCompare(a.updatedAt) || + a.environmentId.localeCompare(b.environmentId) || + a.threadId.localeCompare(b.threadId), + ) + .slice(0, MAX_ACTIVITY_ROWS); + const newest = states.reduce((latest, state) => + state.updatedAt > latest.updatedAt ? state : latest, ); - if (activeStates.length === 0) { - if (input.terminalState !== null) { - return terminalAggregateState(input.terminalState); - } - // Alerts compare with the delivered aggregate; replays must repaint without buzzing. - const recentTerminal = input.activeStates - .filter((state) => isRecentTerminalState(state, input.nowMs)) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - const newest = recentTerminal[0]; - if (!newest) { - return null; - } - return sanitizeAgentActivityAggregateState({ - title: "T3 Code", - subtitle: newest.phase === "failed" ? "Agent work failed" : "Agent work completed", - activeCount: 0, - updatedAt: newest.updatedAt, - activities: recentTerminal.slice(0, MAX_ACTIVITY_ROWS).map(aggregateRowForState), - }); - } - const recentTerminalStates = input.activeStates - .filter((state) => isRecentTerminalState(state, input.nowMs)) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); - const displayedStates = [ - ...activeStates - .toSorted((a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase)) - .slice(0, MAX_ACTIVITY_ROWS), - ...recentTerminalStates, - ].slice(0, MAX_ACTIVITY_ROWS); - const updatedAt = [...activeStates, ...recentTerminalStates].reduce((latest, state) => - state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, - ).updatedAt; return sanitizeAgentActivityAggregateState({ title: "T3 Code", - subtitle: "Agent work in progress", - activeCount: activeStates.length, - updatedAt, + subtitle: + activeCount > 0 + ? "Agent work in progress" + : newest.phase === "failed" + ? "Agent work failed" + : "Agent work completed", + activeCount, + workingCount: states.filter((state) => state.phase === "running" || state.phase === "starting") + .length, + attentionCount: states.filter( + (state) => state.phase === "waiting_for_input" || state.phase === "waiting_for_approval", + ).length, + unreadCount, + // Legacy publishers cannot report reads; keep their existing completion lifecycle. + ...(states.every((state) => state.unread !== undefined) ? { pendingCount } : {}), + totalCount: states.length, + updatedAt: newest.updatedAt, activities: displayedStates.map(aggregateRowForState), }); } -export function activityPhasePriority(phase: RelayAgentActivityState["phase"]): number { +export function activityPhasePriority( + phase: RelayAgentActivityState["phase"], + unread?: boolean, +): number { if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; if (phase === "failed") return 1; - if (phase === "starting" || phase === "running") return 2; - return 3; + if (phase === "completed" && unread !== false) return 2; + if (phase === "starting" || phase === "running") return 3; + return 4; } diff --git a/infra/relay/src/agentActivity/agentActivityAlerts.ts b/infra/relay/src/agentActivity/agentActivityAlerts.ts index c6008950e95e8..19270e9a0deb2 100644 --- a/infra/relay/src/agentActivity/agentActivityAlerts.ts +++ b/infra/relay/src/agentActivity/agentActivityAlerts.ts @@ -36,9 +36,11 @@ export function rowPhasesChanged( ): boolean { if (previousAggregate.activities.length !== nextAggregate.activities.length) return true; const previousPhases = new Map( - previousAggregate.activities.map((row) => [rowKey(row), row.phase]), + previousAggregate.activities.map((row) => [rowKey(row), `${row.phase}:${row.unread}`]), + ); + return nextAggregate.activities.some( + (row) => previousPhases.get(rowKey(row)) !== `${row.phase}:${row.unread}`, ); - return nextAggregate.activities.some((row) => previousPhases.get(rowKey(row)) !== row.phase); } function isAttentionPhase(phase: string): boolean { @@ -88,7 +90,7 @@ function newlyTerminalRows( previousAggregate.activities.map((row) => [rowKey(row), row.phase]), ); return nextAggregate.activities.filter((row) => { - if (row.phase !== "completed" && row.phase !== "failed") return false; + if (row.unread === false || (row.phase !== "completed" && row.phase !== "failed")) return false; const previousPhase = previousPhases.get(rowKey(row)); return ( (includeUnobserved || previousPhase !== undefined) && @@ -142,11 +144,13 @@ export function alertForTerminalAggregate(input: { readonly preferences: RelayAgentAwarenessPreferences | null; }): AgentActivityAlert | null { const row = input.aggregate?.activities[0]; - if (!row || (row.phase !== "completed" && row.phase !== "failed")) return null; + if (!row || row.unread === false || (row.phase !== "completed" && row.phase !== "failed")) + return null; return alertAllowedForPhase(input.preferences, row.phase) ? alertForActivityRows([row]) : null; } export function shouldAlertForActivity(input: { + readonly unread?: boolean | undefined; readonly phase: RelayAgentActivityAggregateRow["phase"]; readonly updatedAt: string; readonly preferences: RelayAgentAwarenessPreferences | null; @@ -154,6 +158,7 @@ export function shouldAlertForActivity(input: { }): boolean { return ( input.preferences?.notificationsEnabled === true && + (!(input.phase === "completed" || input.phase === "failed") || input.unread !== false) && alertAllowedForPhase(input.preferences, input.phase) && ((input.phase !== "completed" && input.phase !== "failed") || isFreshTerminalNotification(input.updatedAt, input.nowMs)) diff --git a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts index e73cfd85145a5..5e2b4f80e5c07 100644 --- a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts +++ b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts @@ -31,6 +31,75 @@ const aggregate = (states: RelayAgentActivityState[]) => makeAggregateState({ activeStates: states, terminalState: null, nowMs: 0 })!; describe("shared agent activity policy", () => { + it("prioritizes actionable results, counts capped rows, and preserves read results", () => { + const make = ( + id: string, + phase: RelayAgentActivityState["phase"], + unread: boolean, + updatedAt = state.updatedAt, + ) => ({ ...state, threadId: ThreadId.make(id), phase, unread, updatedAt, retained: true }); + const states = [ + make("working", "running", false), + make("read", "completed", false), + make("done-old", "completed", true, "1969-12-31T23:59:00.000Z"), + make("done-new", "completed", true), + make("failed", "failed", true), + make("input", "waiting_for_input", false), + ]; + expect(aggregate(states)).toMatchObject({ + activeCount: 2, + workingCount: 1, + attentionCount: 1, + unreadCount: 2, + pendingCount: 5, + totalCount: 6, + activities: [ + { threadId: "input" }, + { threadId: "failed" }, + { threadId: "done-new" }, + { threadId: "done-old" }, + { threadId: "working" }, + ], + }); + expect(aggregate(states.filter((s) => s.threadId !== "done-old"))).toMatchObject({ + totalCount: 5, + activities: [ + { threadId: "input" }, + { threadId: "failed" }, + { threadId: "done-new" }, + { threadId: "working" }, + { threadId: "read", unread: false }, + ], + }); + expect(aggregate([make("read", "completed", false)])).toMatchObject({ + totalCount: 1, + activeCount: 0, + unreadCount: 0, + pendingCount: 0, + }); + }); + + it("does not notify for already read results or marking a result unread again", () => { + const done = { ...state, phase: "completed" as const, unread: false }; + expect(shouldAlertForActivity({ ...done, preferences, nowMs: 0 })).toBe(false); + expect( + terminalTransitionRows({ + previousAggregate: aggregate([state]), + nextAggregate: aggregate([done]), + preferences, + nowMs: 0, + }), + ).toEqual([]); + expect( + terminalTransitionRows({ + previousAggregate: aggregate([done]), + nextAggregate: aggregate([{ ...done, unread: true }]), + preferences, + nowMs: 0, + }), + ).toEqual([]); + }); + it.each(["waiting_for_approval", "waiting_for_input"] as const)( "keeps an older %s ahead of five running rows", (phase) => { diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 6c813235e4f61..cc82429d1f9b9 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -5,39 +5,72 @@ import { } from "./agentActivityAggregate.ts"; import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; -export function androidActivityData(aggregate: RelayAgentActivityAggregateState | null) { +export function androidActivityData( + aggregate: RelayAgentActivityAggregateState | null, + nowMs = Date.parse(aggregate?.updatedAt ?? ""), +) { const rows = [...(aggregate?.activities ?? [])].sort( - (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), + (a, b) => + activityPhasePriority(a.phase, a.unread) - activityPhasePriority(b.phase, b.unread) || + b.updatedAt.localeCompare(a.updatedAt), ); const activeCount = aggregate?.activeCount ?? 0; - const attentionCount = rows.filter((row) => activityPhasePriority(row.phase) === 0).length; + const attentionCount = + aggregate?.attentionCount ?? + rows.filter((row) => activityPhasePriority(row.phase) === 0).length; + const workingCount = aggregate?.workingCount ?? Math.max(0, activeCount - attentionCount); + const unreadCount = aggregate?.unreadCount ?? 0; + const active = (aggregate?.pendingCount ?? activeCount) > 0; const failed = rows.some((row) => row.phase === "failed"); const clean = (value: string) => value.replace(/\s+/g, " ").trim(); const lines = rows.map((row) => - [row.status, clean(row.threadTitle), clean(row.projectTitle)].join("\t"), + [ + row.phase === "completed" && row.unread === false + ? elapsedLabel(nowMs - Date.parse(row.completedAt ?? row.updatedAt)) + : row.status, + clean(row.threadTitle), + clean(row.projectTitle), + ].join("\t"), ); const hero = rows[0]; - const title = - activeCount > 0 - ? `${activeCount} active agent${activeCount === 1 ? "" : "s"}${attentionCount ? ` · ${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : ""}` - : failed - ? "Agent work failed" - : "Agent work completed"; + const title = active + ? [ + workingCount ? `${workingCount} working` : "", + attentionCount ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : "", + unreadCount ? `${unreadCount} done` : "", + ] + .filter(Boolean) + .join(" · ") + : failed + ? "Agent work failed" + : "All caught up"; const expiresAt = Math.max( 0, ...rows.map((row) => row.phase === "completed" || row.phase === "failed" - ? Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS + ? (aggregate?.pendingCount === undefined ? Date.parse(row.updatedAt) : nowMs) + + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS : agentActivityExpiresAt(row), ), ); return { - active: String(activeCount > 0), - activity_chip: activeCount > 0 ? (attentionCount > 0 ? "Review" : "Active") : "", + active: String(active), + activity_chip: active + ? attentionCount > 0 + ? "Review" + : unreadCount > 0 + ? "Done" + : "Active" + : "", activity_title: title, activity_phase: hero?.phase ?? "", activity_active_count: String(activeCount), + activity_working_count: String(workingCount), + ...(aggregate?.totalCount === undefined + ? {} + : { activity_total_count: String(aggregate.totalCount) }), activity_attention_count: String(attentionCount), + activity_unread_count: String(aggregate?.unreadCount ?? 0), activity_body: hero ? `${hero.status}: ${clean(hero.threadTitle)} · ${clean(hero.projectTitle)}` : "", @@ -47,6 +80,18 @@ export function androidActivityData(aggregate: RelayAgentActivityAggregateState }; } +function elapsedLabel(ageMs: number): string { + if (!Number.isFinite(ageMs)) return "Read"; + const minutes = Math.max(0, Math.floor(ageMs / 60_000)); + return minutes < 1 + ? "now" + : minutes < 60 + ? `${minutes}m` + : minutes < 1_440 + ? `${Math.floor(minutes / 60)}h` + : `${Math.floor(minutes / 1_440)}d`; +} + /** Keep Unicode, escaping and grouped alerts within FCM's 4 KB data budget. */ export function fitFcmData(input: Readonly>): Record { const data = { ...input }; diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index bf96390d8d42d..3dbd6c34afe01 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -137,6 +137,22 @@ describe("EnvironmentPublishSignatures", () => { ); }); + it.effect("rejects an unsigned backfill flag", () => + Effect.gen(function* () { + const request = yield* freshRequest; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request: { ...request, replay: true }, + }), + ); + expect(Result.isFailure(result)).toBe(true); + }).pipe(Effect.provide(layer())), + ); + it.effect("rejects top-level state tampering", () => Effect.gen(function* () { const request = yield* freshRequest; diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts index 4c89bff5342b0..b5a6cbca75bbf 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -148,6 +148,7 @@ const make = Effect.gen(function* () { proof.threadId !== input.threadId || proof.sub !== input.environmentId || stableStringify(proof.state) !== stableStringify(input.request.state) || + (proof.replay ?? false) !== (input.request.replay ?? false) || (input.request.state !== null && (input.request.state.environmentId !== input.environmentId || input.request.state.threadId !== input.threadId)) diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 5a4d77c28d3c2..15161f58b2138 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -1234,6 +1234,7 @@ export const layerServerApi = HttpApiBuilder.group( environmentPublicKey: principal.environmentPublicKey, threadId: params.threadId, state: payload.state, + replay: payload.replay ?? false, }); }, mapErrorTags({ diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 4dbb6a199f472..54dce6435d36b 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -122,7 +122,10 @@ export const RelayAgentActivityState = Schema.Struct({ projectTitle: TrimmedNonEmptyString, threadTitle: TrimmedNonEmptyString, phase: RelayAgentAwarenessPhase, + unread: Schema.optional(Schema.Boolean), + completedAt: Schema.optional(TrimmedNonEmptyString), headline: TrimmedNonEmptyString, + retained: Schema.optional(Schema.Boolean), detail: Schema.optional(TrimmedNonEmptyString), modelTitle: TrimmedNonEmptyString, updatedAt: TrimmedNonEmptyString, @@ -137,6 +140,8 @@ export const RelayAgentActivityAggregateRow = Schema.Struct({ threadTitle: TrimmedNonEmptyString, modelTitle: TrimmedNonEmptyString, phase: RelayAgentAwarenessPhase, + unread: Schema.optional(Schema.Boolean), + completedAt: Schema.optional(TrimmedNonEmptyString), status: TrimmedNonEmptyString, updatedAt: TrimmedNonEmptyString, deepLink: TrimmedNonEmptyString, @@ -147,6 +152,11 @@ export const RelayAgentActivityAggregateState = Schema.Struct({ title: TrimmedNonEmptyString, subtitle: TrimmedNonEmptyString, activeCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + workingCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + attentionCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + totalCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + unreadCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + pendingCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), updatedAt: TrimmedNonEmptyString, activities: Schema.Array(RelayAgentActivityAggregateRow), }); @@ -263,11 +273,13 @@ export const RelayAgentActivityPublishProofPayload = Schema.Struct({ environmentId: EnvironmentId, threadId: ThreadId, state: Schema.NullOr(RelayAgentActivityState), + replay: Schema.optional(Schema.Boolean), }); export type RelayAgentActivityPublishProofPayload = typeof RelayAgentActivityPublishProofPayload.Type; export const RelayAgentActivityPublishRequest = Schema.Struct({ + replay: Schema.optional(Schema.Boolean), state: Schema.NullOr(RelayAgentActivityState).annotate({ description: "Current agent-awareness state, or null to remove the published state.", }), diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index caf261bfbb1e9..8e7fdb4ce7393 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -48,6 +48,48 @@ describe("projectThreadAwarenessV2", () => { ...overrides, }); + it("excludes archived and snoozed inbox rows, then restores them on wake", () => { + const base = { + environmentId: "env-1" as EnvironmentId, + project, + now: Date.parse(NOW), + thread: v2Thread({ status: "completed" }), + }; + const snoozedUntil = DateTime.makeUnsafe("2026-05-22T13:00:00.000Z"); + expect( + projectThreadAwarenessV2({ ...base, thread: { ...base.thread, archivedAt: updatedAt } }), + ).toBeNull(); + expect( + projectThreadAwarenessV2({ ...base, thread: { ...base.thread, snoozedUntil } }), + ).toBeNull(); + expect( + projectThreadAwarenessV2({ + ...base, + now: Date.parse("2026-05-22T13:01:00.000Z"), + thread: { ...base.thread, snoozedUntil }, + }), + ).toMatchObject({ phase: "completed", retained: true }); + }); + + it.each([ + { visit: null, unread: false }, + { visit: "2026-05-22T11:59:00.000Z", unread: true }, + { visit: NOW, unread: false }, + { visit: "2026-05-22T12:01:00.000Z", unread: false }, + ])("projects the desktop completion read state: $visit", ({ visit, unread }) => { + expect( + projectThreadAwarenessV2({ + environmentId: "env-1" as EnvironmentId, + project, + thread: { + ...v2Thread({ status: "completed" }), + latestRunCompletedAt: updatedAt, + lastVisitedAt: visit === null ? null : DateTime.makeUnsafe(visit), + }, + }), + ).toMatchObject({ phase: "completed", unread, completedAt: NOW, retained: true }); + }); + it("does not announce completion after a manual stop", () => { const input = { environmentId: "env-1" as EnvironmentId, @@ -103,21 +145,18 @@ describe("projectThreadAwarenessV2", () => { ).toBeNull(); }); - it.each(["starting", "running", "waiting"] as const)( - "keeps settled %s work visible", - (status) => { - expect( - projectThreadAwarenessV2({ - environmentId: "env-1" as EnvironmentId, - project, - thread: v2Thread({ status, settledOverride: "settled" }), - }), - ).toMatchObject({ phase: status === "starting" ? "starting" : "running" }); - }, - ); + it.each(["starting", "running", "waiting"] as const)("excludes settled %s work", (status) => { + expect( + projectThreadAwarenessV2({ + environmentId: "env-1" as EnvironmentId, + project, + thread: v2Thread({ status, settledOverride: "settled" }), + }), + ).toBeNull(); + }); it.each(["user_input", "command"] as const)( - "keeps a settled terminal thread with a pending %s request", + "excludes a settled thread with a pending %s request", (kind) => { expect( projectThreadAwarenessV2({ @@ -133,13 +172,11 @@ describe("projectThreadAwarenessV2", () => { }, }), }), - ).toMatchObject({ - phase: kind === "user_input" ? "waiting_for_input" : "waiting_for_approval", - }); + ).toBeNull(); }, ); - it("keeps settled work held open by an awaited monitor", () => { + it("excludes a settled thread with an awaited monitor", () => { expect( projectThreadAwarenessV2({ environmentId: "env-1" as EnvironmentId, @@ -150,7 +187,7 @@ describe("projectThreadAwarenessV2", () => { pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor", awaited: true }], }), }), - ).toMatchObject({ phase: "running" }); + ).toBeNull(); }); it.each([ diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index df912b4bb0f1b..5d1aef4858204 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -24,6 +24,9 @@ export interface AgentAwarenessState { readonly threadTitle: string; readonly phase: AgentAwarenessPhase; readonly headline: string; + readonly retained?: boolean; + readonly unread?: boolean; + readonly completedAt?: string; readonly detail?: string; readonly modelTitle: string; readonly updatedAt: string; @@ -40,40 +43,65 @@ function buildAgentAwarenessDeepLink(input: { export interface ProjectThreadAwarenessV2Input { readonly environmentId: EnvironmentId; readonly project: Pick; - readonly thread: Pick< - OrchestrationV2ThreadShell, - | "activityRunStatus" - | "id" - | "lineage" - | "latestRunStopRequested" - | "modelSelection" - | "pendingBackgroundTasks" - | "pendingRuntimeRequest" - | "settledOverride" - | "status" - | "title" - | "updatedAt" - >; + readonly now?: number; + readonly thread: Partial< + Pick< + OrchestrationV2ThreadShell, + "archivedAt" | "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "lastVisitedAt" + > + > & + Pick< + OrchestrationV2ThreadShell, + | "activityRunStatus" + | "id" + | "lineage" + | "latestRunStopRequested" + | "modelSelection" + | "pendingBackgroundTasks" + | "pendingRuntimeRequest" + | "settledOverride" + | "status" + | "title" + | "updatedAt" + >; } export function projectThreadAwarenessV2( input: ProjectThreadAwarenessV2Input, ): AgentAwarenessState | null { const { environmentId, project, thread } = input; - if (thread.lineage.relationshipToParent === "subagent") return null; + if ( + thread.lineage.relationshipToParent === "subagent" || + thread.archivedAt != null || + thread.settledOverride === "settled" + ) + return null; + if ( + input.now !== undefined && + thread.snoozedUntil != null && + DateTime.toEpochMillis(thread.snoozedUntil) > input.now + ) { + const failedAfterSnooze = + (thread.activityRunStatus ?? thread.status) === "failed" && + (thread.snoozedAt == null || + (thread.latestRunCompletedAt != null && + DateTime.toEpochMillis(thread.latestRunCompletedAt) > + DateTime.toEpochMillis(thread.snoozedAt))); + if (thread.pendingRuntimeRequest == null && !failedAfterSnooze) return null; + } const phase = resolveThreadAwarenessPhaseV2(thread); if (phase === null) { return null; } if (phase === "completed" && thread.latestRunStopRequested === true) return null; - // Resolve the phase first so live or waiting work stays visible after settlement. - if ( - thread.settledOverride === "settled" && + const completedAt = + phase === "completed" || phase === "failed" ? thread.latestRunCompletedAt : null; + const unread = (phase === "completed" || phase === "failed") && - !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) - ) { - return null; - } + (thread.lastVisitedAt == null + ? phase === "failed" + : completedAt != null && + DateTime.toEpochMillis(completedAt) > DateTime.toEpochMillis(thread.lastVisitedAt)); const detail = phase === "completed" ? "Review the completed task." @@ -87,6 +115,9 @@ export function projectThreadAwarenessV2( threadTitle: thread.title, phase, headline: headlineForPhase(phase), + retained: true, + unread, + ...(completedAt == null ? {} : { completedAt: DateTime.formatIso(completedAt) }), ...(detail === undefined ? {} : { detail }), modelTitle: thread.modelSelection.model, updatedAt: DateTime.formatIso(thread.updatedAt), diff --git a/patches/@expo__ui@58.0.20.patch b/patches/@expo__ui@58.0.20.patch index 2546d83b80428..c924feb677281 100644 --- a/patches/@expo__ui@58.0.20.patch +++ b/patches/@expo__ui@58.0.20.patch @@ -343,3 +343,37 @@ index 77fe3d7..3fa0fb9 100644 import './MaterialSymbolsAssetsTransformer.fx'; export * from './AlertDialog'; +diff --git a/ios/TextView.swift b/ios/TextView.swift +--- a/ios/TextView.swift ++++ b/ios/TextView.swift +@@ -7 +7 @@ +- case timer, relative, offset, date, time ++ case timer, relative, relativeMinutes, offset, date, time +@@ -12 +12 @@ +- case .relative: return .relative ++ case .relative, .relativeMinutes: return .relative +@@ -57,0 +58,12 @@ ++ } else if let date = props.date, props.dateStyle == .relativeMinutes { ++ if #available(iOS 18.0, tvOS 18.0, macOS 15.0, watchOS 11.0, *) { ++ text = Text(.currentDate, format: .offset( ++ to: date, allowedFields: [.day, .hour, .minute], maxFieldCount: 3, sign: .never ++ )) ++ } else { ++ let formatter = DateComponentsFormatter() ++ formatter.allowedUnits = [.day, .hour, .minute] ++ formatter.unitsStyle = .full ++ let minutes = max(0, Int(Date().timeIntervalSince(date) / 60)) ++ text = Text(formatter.string(from: TimeInterval(minutes * 60)) ?? "0 minutes") ++ } +diff --git a/src/swift-ui/Text/index.tsx b/src/swift-ui/Text/index.tsx +--- a/src/swift-ui/Text/index.tsx ++++ b/src/swift-ui/Text/index.tsx +@@ -10 +10 @@ +-export type TextDateStyle = 'timer' | 'relative' | 'offset' | 'date' | 'time'; ++export type TextDateStyle = 'timer' | 'relative' | 'relativeMinutes' | 'offset' | 'date' | 'time'; +diff --git a/build/swift-ui/Text/index.d.ts b/build/swift-ui/Text/index.d.ts +--- a/build/swift-ui/Text/index.d.ts ++++ b/build/swift-ui/Text/index.d.ts +@@ -6 +6 @@ +-export type TextDateStyle = 'timer' | 'relative' | 'offset' | 'date' | 'time'; ++export type TextDateStyle = 'timer' | 'relative' | 'relativeMinutes' | 'offset' | 'date' | 'time'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bdece6bd1839..f0f85de20c061 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,7 +361,7 @@ patchedDependencies: '@effect/vitest@4.0.5': 2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02 '@expo/fingerprint@0.21.5': 8fba80d6bbce46c72de427fe8c7907462d262a0bfe50c6b2b7ee9c4e0c61e5e1 '@expo/metro-config@58.0.14': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 - '@expo/ui@58.0.20': 5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e + '@expo/ui@58.0.20': e6d9a8c99676a662e99bd69ae5ffc8d9aef4c2f6e03b82f987f1f8265b72fc57 '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c '@legendapp/list@3.3.5': 6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 @@ -538,7 +538,7 @@ importers: version: 58.0.14(@expo/log-box@58.0.12)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@expo/ui': specifier: 58.0.20 - version: 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.20(patch_hash=e6d9a8c99676a662e99bd69ae5ffc8d9aef4c2f6e03b82f987f1f8265b72fc57)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@legendapp/list': specifier: 3.3.5 version: 3.3.5(patch_hash=6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -14342,7 +14342,7 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - ? '@expo/ui@58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)' + ? '@expo/ui@58.0.20(patch_hash=e6d9a8c99676a662e99bd69ae5ffc8d9aef4c2f6e03b82f987f1f8265b72fc57)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)' : dependencies: expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) react: 19.3.0 @@ -18593,7 +18593,7 @@ snapshots: ? expo-widgets@58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) : dependencies: '@expo/plist': 0.10.1 - '@expo/ui': 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + '@expo/ui': 58.0.20(patch_hash=e6d9a8c99676a662e99bd69ae5ffc8d9aef4c2f6e03b82f987f1f8265b72fc57)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) From 25ad42ebc2f89a4a533015f8411af8613a4a4dfe Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 27 Oct 2026 23:15:00 -0400 Subject: [PATCH 02/18] fix(mobile): handle replay races and terminal activity headers --- apps/mobile/src/widgets/AgentActivity.tsx | 12 +++-- .../src/relay/AgentAwarenessRelay.test.ts | 34 ++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 16 +++++- .../src/agentActivity/ApnsDeliveries.test.ts | 53 +++++++++++++++++++ .../relay/src/agentActivity/ApnsDeliveries.ts | 4 +- .../src/agentActivity/FcmDeliveries.test.ts | 26 +++++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 5 +- .../agentActivity/agentActivityAggregate.ts | 5 +- .../agentActivity/agentActivityPolicy.test.ts | 18 +++++++ infra/relay/src/agentActivity/fcmPayloads.ts | 21 ++++---- infra/relay/src/http/Api.test.ts | 1 + 11 files changed, 176 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index df2c00d443f77..b75c19dcd712e 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -192,7 +192,7 @@ function AgentActivity( const allDone = typeof props.pendingCount === "number" && props.pendingCount >= 0 ? props.pendingCount === 0 - : activeCount === 0 && unreadCount === 0; + : activeCount === 0 && (unreadCount === 0 || unreadCount === null); const hasRows = activities.length > 0; const doneLabel = failedRow ? "Failed" : hasRows ? "Done" : "Idle"; const outcomeLabel = failedRow @@ -209,10 +209,12 @@ function AgentActivity( ? "Working count unavailable" : workingCount > 0 ? `${workingCount} working` - : ""; + : failedRow + ? "Agent work failed" + : ""; const agentsLabel = countLabel; const attentionSuffix = - attentionCount !== null && attentionCount > 0 + !isSystemStale && attentionCount !== null && attentionCount > 0 ? `${attentionCount} need${attentionCount === 1 ? "s" : ""} you` : ""; const doneSuffix = unreadCount !== null && unreadCount > 0 ? `${unreadCount} done` : ""; @@ -224,7 +226,9 @@ function AgentActivity( ? "Count unavailable" : workingCount > 0 ? `${workingCount} working` - : ""; + : failedRow + ? "Failed" + : ""; const activeLabel = currentLabel; const showCompactGlyph = !attentionRow && !failedRow && !doneSuffix && !allDone && !isSystemStale; const summary = attentionSuffix || doneSuffix || activeLabel; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b0622f54dd63b..3cfa12f3999c1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -532,6 +532,40 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("does not alert again when a completed thread is settled then reopened", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completedAt = DateTime.makeUnsafe("1970-01-01T00:00:01.000Z"); + const completed = shell({ + status: "completed", + latestRunCompletedAt: completedAt, + lastVisitedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.replay, false); + yield* Ref.set(currentShell, { ...completed, settledOverride: "settled" }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state, null); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + assert.equal(publications.at(-1)?.replay, true); + const nextCompletedAt = yield* DateTime.now; + yield* Ref.set(currentShell, shell({ ...completed, latestRunCompletedAt: nextCompletedAt })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.replay, false); + }), + ); + it.effect("holds a completion through a continuation gap, then publishes it once", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index c4c18a4833982..d694556568a9d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -376,6 +376,11 @@ export const make = Effect.gen(function* () { const publishedStateByThreadRef = yield* Ref.make( new Map(), ); + // Tombstones remove published rows; reopening must still recognize an already announced result. + const publishedCompletions = new Map< + ThreadId, + Pick + >(); const readSecretString = (name: string) => secrets @@ -541,9 +546,10 @@ export const make = Effect.gen(function* () { const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); const previous = publishedStateByThread.get(threadId); + const previousCompletion = publishedCompletions.get(threadId); const sameCompletion = - previous?.state.phase === snapshot.state?.phase && - previous?.state.completedAt === snapshot.state?.completedAt; + previousCompletion?.phase === snapshot.state?.phase && + previousCompletion?.completedAt === snapshot.state?.completedAt; const replay = (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && (sameCompletion || @@ -615,6 +621,12 @@ export const make = Effect.gen(function* () { reason: snapshot.reason, replay, }).pipe(withRelayClientTracing); + if (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") { + publishedCompletions.set(threadId, { + phase: snapshot.state.phase, + completedAt: snapshot.state.completedAt, + }); + } publishConfirmDeadlines.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index b9d4f206cac70..7aff9b4f00980 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1133,6 +1133,59 @@ describe("fast completion delivery", () => { }, ); + it.effect.each([true, false])( + "preserves a freshly armed token only for a replayed read snapshot: replay=%s", + (replay) => { + const queuedJobs: SignedApnsDeliveryJob[] = []; + const read = { + ...aggregate, + activeCount: 0, + pendingCount: 0, + unreadCount: 0, + activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, unread: false }], + }; + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.sendForTarget({ target, aggregate: read, nowMs: 0, replay }); + expect(queuedJobs).toHaveLength(replay ? 0 : 1); + }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs }))); + }, + ); + + it.effect("does not deliver a last-read end after another thread starts", () => { + const queuedJobs: SignedApnsDeliveryJob[] = []; + const requests: HttpClientRequest.HttpClientRequest[] = []; + const read = { ...state, phase: "completed" as const, unread: false }; + const readAggregate = { + ...aggregate, + activeCount: 0, + pendingCount: 0, + unreadCount: 0, + activities: [{ ...aggregate.activities[0]!, phase: "completed" as const, unread: false }], + }; + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.sendForTarget({ target, aggregate: readAggregate, nowMs: 20_000 }); + expect(queuedJobs).toHaveLength(1); + yield* deliveries.processSignedJob(queuedJobs[0]!); + expect(requests).toHaveLength(0); + }).pipe( + Effect.provide( + layerFor({ + attempts: [], + queuedJobs, + config: signingConfig, + activityStates: [read, { ...state, threadId: ThreadId.make("new-work") }], + execute: (request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, Response.json({})); + }), + }), + ), + ); + }); + it.effect("keeps a completion alert when work finishes before running delivery", () => { const queuedJobs: SignedApnsDeliveryJob[] = []; const old = { diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index bc1a124fcf488..df01374f62897 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -242,7 +242,7 @@ function chooseLiveActivityDelivery(input: { { onNone: () => null, onSome: (dt) => dt.epochMilliseconds }, ); if ( - input.aggregate === null && + (input.aggregate === null || input.replay) && armedAtMs !== null && input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS ) { @@ -251,7 +251,7 @@ function chooseLiveActivityDelivery(input: { return { kind: "live_activity_end", token: input.target.activity_push_token, - aggregate: input.aggregate, + aggregate: null, alert: null, }; } diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index c6640e530e559..689ded28a3e10 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -250,6 +250,32 @@ describe("Android delivery routing", () => { }, ); + it.effect( + "a per-thread replay does not consume another thread's pending completion alert", + () => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process(h.job); + h.current.otherStates = [{ ...secondState, phase: "completed", unread: true }]; + const baseline = h.current.target.last_aggregate_json; + yield* delivery.process({ ...h.job, replay: true }); + expect(h.current.target.last_aggregate_json).toBe(baseline); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it("expires an Android card immediately after its last result is read", () => { + const read = { ...state, phase: "completed" as const, unread: false, retained: true }; + expect(androidActivityData(aggregateFor([read]), 60_000)).toMatchObject({ + active: "false", + activity_expires_at: "0", + }); + }); + it.effect("filters disabled event types before counting a group", () => { const h = harness(); h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, secondState])); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 216217144234f..b094a9c02ef71 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -213,7 +213,10 @@ const make = Effect.gen(function* () { : null; let alert: ReturnType = null; // Deletions update the card but must not consume another thread's pending completion alert. - let acknowledgeAggregate = job.state !== null || job.replay === true || aggregate === null; + // Full registration replays establish a baseline; per-thread replays cannot consume other alerts. + let acknowledgeAggregate = job.replay + ? job.state === null + : job.state !== null || aggregate === null; if (job.state && !job.replay && preferences.value.notificationsEnabled) { const state = yield* rows.getForUserThread({ userId: job.userId, diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts index 000c9d2ab59a3..61d1234cf6119 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -109,7 +109,10 @@ export function makeAggregateState(input: { ).length, unreadCount, // Legacy publishers cannot report reads; keep their existing completion lifecycle. - ...(states.every((state) => state.unread !== undefined) ? { pendingCount } : {}), + ...(states.every((state) => state.unread !== undefined) || + states.some((state) => state.unread === true) + ? { pendingCount } + : {}), totalCount: states.length, updatedAt: newest.updatedAt, activities: displayedStates.map(aggregateRowForState), diff --git a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts index 5e2b4f80e5c07..aa6edaa764a9b 100644 --- a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts +++ b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts @@ -79,6 +79,24 @@ describe("shared agent activity policy", () => { }); }); + it.each(["completed", "failed"] as const)( + "keeps an unread modern %s pending alongside legacy results", + (phase) => { + const modern = { + ...state, + threadId: ThreadId.make("modern"), + phase, + unread: true, + retained: true, + }; + const legacy = { ...state, phase: "completed" as const }; + const mixed = aggregate([modern, legacy]); + expect(mixed.pendingCount).toBe(2); + expect(mixed.activeCount).toBe(0); + expect(aggregate([legacy]).pendingCount).toBeUndefined(); + }, + ); + it("does not notify for already read results or marking a result unread again", () => { const done = { ...state, phase: "completed" as const, unread: false }; expect(shouldAlertForActivity({ ...done, preferences, nowMs: 0 })).toBe(false); diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index cc82429d1f9b9..6131a282a5482 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -44,15 +44,18 @@ export function androidActivityData( : failed ? "Agent work failed" : "All caught up"; - const expiresAt = Math.max( - 0, - ...rows.map((row) => - row.phase === "completed" || row.phase === "failed" - ? (aggregate?.pendingCount === undefined ? Date.parse(row.updatedAt) : nowMs) + - TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS - : agentActivityExpiresAt(row), - ), - ); + const expiresAt = + aggregate?.pendingCount === 0 + ? 0 + : Math.max( + 0, + ...rows.map((row) => + row.phase === "completed" || row.phase === "failed" + ? (aggregate?.pendingCount === undefined ? Date.parse(row.updatedAt) : nowMs) + + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS + : agentActivityExpiresAt(row), + ), + ); return { active: String(active), activity_chip: active diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index a20989cd3c025..79cf5eb440b06 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -1451,6 +1451,7 @@ describe("relay routing fallback", () => { environmentPublicKey, threadId, state: null, + replay: false, })), ); expect(verified.map((input) => input.threadId)).toEqual(threadIds); From 56552f5dbace2c0a9219bc1f9b458279a86d1d46 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 27 Oct 2026 23:28:07 -0400 Subject: [PATCH 03/18] fix(mobile): preserve pending activity and off-card alerts --- .../AgentActivityPresentation.kt | 2 +- .../AgentNotifications.kt | 10 +- .../AgentNotificationsTest.kt | 38 +++++- .../src/relay/AgentAwarenessRelay.test.ts | 128 +++++++++++++++++- apps/server/src/relay/AgentAwarenessRelay.ts | 42 +++++- .../AgentActivityPublisher.test.ts | 74 ++++++++++ .../agentActivity/AgentActivityPublisher.ts | 9 +- .../src/agentActivity/FcmDeliveries.test.ts | 51 +++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 40 ++++-- infra/relay/src/agentActivity/fcmPayloads.ts | 21 ++- 10 files changed, 384 insertions(+), 31 deletions(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt index 6462b92d4494d..1c64692996fbb 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt @@ -139,7 +139,7 @@ internal class ActivityPresentation(data: Map, private val activ builder.setSubText( listOfNotNull( hero!!.project.takeIf { singleProject && it.isNotBlank() }, - if (activeCount > 0) "$workingCount working" else "$threadCount threads" + if (workingCount > 0) "$workingCount working" else "$threadCount threads" ).joinToString(" · ") ) } diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index f9b69a7117741..77ccf3a3d4ebe 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -226,10 +226,13 @@ object AgentNotifications { // make an abandoned host look active indefinitely. Older relays omit it. val expiresAt = data["activity_expires_at"]?.toLongOrNull() ?: if (active) updatedAt + RUNNING_LIFETIME_MS else 0L - val remainingMs = (expiresAt - System.currentTimeMillis()).coerceAtMost(MAX_LIFETIME_MS) + // A retained unread result has no timeout; zero on an inactive payload still clears the card. + val retained = active && expiresAt == 0L + val remainingMs = if (retained) 0L else + (expiresAt - System.currentTimeMillis()).coerceAtMost(MAX_LIFETIME_MS) val wasActive = prefs.getBoolean("lastActive", false) prefs.edit().putBoolean("lastActive", active).apply() - if (remainingMs <= 0 || !prefs.getBoolean("ongoing", false)) { + if ((!retained && remainingMs <= 0) || !prefs.getBoolean("ongoing", false)) { cancelActivity(context) prefs.edit().putBoolean("dismissed", false).apply() return @@ -274,6 +277,9 @@ object AgentNotifications { } manager(context).notify(ACTIVITY_TAG, ACTIVITY_ID, builder.build()) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) + context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().remove("expiresAt").apply() + if (remainingMs <= 0) return // Notification timeouts were added in API 26. One inexact alarm also // expires cards on Android 7, including when the app process has exited. // No exact-alarm permission, foreground service or periodic work needed. diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 7ca6edbc2133e..001cd804e1b35 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -67,6 +67,23 @@ class AgentNotificationsTest { "alert_path" to "/threads/environment/thread", ) + @Test + fun retainedUnreadResultHasNoTimeoutAndReadingItClearsTheCard() { + AgentNotifications.receive(context, update("done", true) + mapOf( + "activity_phase" to "completed", + "activity_expires_at" to "0", + )) + val card = manager.activeNotifications.single().notification + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) assertEquals(0L, card.timeoutAfter) + assertTrue(shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty()) + AgentNotifications.expire(context, System.currentTimeMillis() + 24 * 60 * 60 * 1000L) + assertEquals(1, manager.activeNotifications.size) + AgentNotifications.receive(context, update("done-read", false) + mapOf( + "activity_expires_at" to "0", + )) + assertEquals(0, manager.activeNotifications.size) + } + @Test fun alertHistoryEvictsOnlyTheOldestEntryAfterCapacity() { lifecycle.currentState = Lifecycle.State.RESUMED @@ -342,7 +359,7 @@ class AgentNotificationsTest { AgentNotifications.receive(context, data) val card = manager.activeNotifications.single().notification assertEquals("1 needs you", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) - assertEquals("8 active", card.extras.getString(Notification.EXTRA_SUB_TEXT)) + assertEquals("7 working", card.extras.getString(Notification.EXTRA_SUB_TEXT)) assertEquals( "Approval $title · Project\nWorking Another thread · Other project", card.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() @@ -464,6 +481,23 @@ class AgentNotificationsTest { assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)) } + @Test + fun waitingThreadsShowTheirCountInsteadOfZeroWorking() { + lifecycle.currentState = Lifecycle.State.RESUMED + val data = update("waiting-only", true) + mapOf( + "activity_line_0" to "Input\tChoose a model\tProject", + "activity_line_1" to "Input\tChoose an icon\tProject", + "activity_phase" to "waiting_for_input", + "activity_active_count" to "2", + "activity_attention_count" to "2", + "activity_working_count" to "0", + "activity_total_count" to "2", + ) + AgentNotifications.receive(context, data) + val card = manager.activeNotifications.single().notification + assertEquals("Project · 2 threads", card.extras.getString(Notification.EXTRA_SUB_TEXT)) + } + @Test fun multipleAgentsUseTheChipCountAndFinishedThreadsRemainInTheSummary() { lifecycle.currentState = Lifecycle.State.RESUMED @@ -477,7 +511,7 @@ class AgentNotificationsTest { val card = manager.activeNotifications.single().notification assertEquals("2 live", card.extras.getString(NotificationCompat.EXTRA_SHORT_CRITICAL_TEXT)) assertEquals("2 working", card.extras.getCharSequence(Notification.EXTRA_TITLE).toString()) - assertEquals("Project · 2 active", card.extras.getString(Notification.EXTRA_SUB_TEXT)) + assertEquals("Project · 2 working", card.extras.getString(Notification.EXTRA_SUB_TEXT)) assertEquals( "Working Build feature\nDone Write tests", card.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 3cfa12f3999c1..9bf969e47d510 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -117,6 +117,24 @@ describe("startup agent activity", () => { assert.equal(publications[0]?.replay, true); }), ); + it.each([30_000, 120_000])( + "uses the current time for snoozes in a delayed backfill: elapsed=%s", + (elapsed) => { + const startedAt = DateTime.toEpochMillis(DateTime.makeUnsafe(NOW)); + const input = { + environmentId: EnvironmentId.make("relay-env"), + startedAt, + now: startedAt + elapsed, + projects: [{ id: PROJECT_ID, title: "Project" }], + threads: [shell({ snoozedUntil: DateTime.makeUnsafe(startedAt + 60_000) })], + }; + assert.deepEqual( + AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds(input), + elapsed < 60_000 ? [] : [THREAD_ID], + ); + }, + ); + it("publishes active work and older unsettled completions after startup", () => { const startedAt = DateTime.toEpochMillis(DateTime.makeUnsafe(NOW)); const oldCompleted = ThreadId.make("old-completed"); @@ -125,7 +143,7 @@ describe("startup agent activity", () => { const newFailed = ThreadId.make("new-failed"); const ids = AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ environmentId: EnvironmentId.make("relay-env"), - startedAt, + now: startedAt, projects: [{ id: PROJECT_ID, title: "Project" }], threads: [ shell(), @@ -164,6 +182,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( readonly linked?: boolean; readonly readShell?: (threadId: ThreadId) => Effect.Effect; readonly domainEvents?: Stream.Stream; + readonly backfillThread?: OrchestrationV2ThreadShell; } = {}, ) { const values = new Map( @@ -196,7 +215,9 @@ const makeTestRelay = Effect.fnUntraced(function* ( remove: (name) => Effect.sync(() => void values.delete(name)), getOrCreateRandom: unused, }); - const currentShell = yield* Ref.make(shell()); + const currentShell = yield* Ref.make( + options.backfillThread ?? shell(), + ); const shellReads: ThreadId[] = []; const catchUp = { shellSnapshotReads: 0 }; const threads = ThreadManagementService.ThreadManagementService.of({ @@ -207,7 +228,12 @@ const makeTestRelay = Effect.fnUntraced(function* ( getShellSnapshot: () => Effect.sync(() => { catchUp.shellSnapshotReads += 1; - return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; + return { + schemaVersion: 2, + snapshotSequence: 1, + threads: options.backfillThread ? [options.backfillThread] : [], + archivedThreads: [], + }; }), listActiveSubagentThreadIds: unused, listOlderSettledThreads: unused, @@ -289,7 +315,22 @@ const makeTestRelay = Effect.fnUntraced(function* ( update: unused, delete: unused, getByWorkspaceRoot: unused, - snapshot: Effect.succeed({ projects: [] } as never), + snapshot: Effect.succeed({ + projects: options.backfillThread + ? [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }, + ] + : [], + } as never), listPage: unused, getShell: unused, listShells: unused, @@ -532,6 +573,85 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("restores a snoozed result after startup backfill without an unsnooze event", () => + Effect.gen(function* () { + const { relay, publications } = yield* makeTestRelay({ + backfillThread: shell({ status: "completed", snoozedUntil: DateTime.makeUnsafe(60_000) }), + }); + yield* relay.start(); + yield* TestClock.adjust("1 second"); + yield* relay.drain; + assert.equal(publications.length, 0); + yield* TestClock.adjust("59 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + assert.equal(publications.at(-1)?.replay, true); + }).pipe(Effect.scoped), + ); + + it.effect("reschedules a changed snooze deadline and ignores the old deadline", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + const completed = shell({ status: "completed", snoozedUntil: DateTime.makeUnsafe(60_000) }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { ...completed, snoozedUntil: DateTime.makeUnsafe(120_000) }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + const before = publications.length; + yield* TestClock.adjust("50 seconds"); + yield* relay.drain; + assert.equal(publications.length, before); + yield* TestClock.adjust("60 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + }), + ); + + it.effect("restores a snoozed result at expiry without another domain event or alert", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.at(-1)?.state?.phase, "completed"); + yield* Ref.set(currentShell, { + ...completed, + snoozedUntil: DateTime.makeUnsafe(60_000), + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state, null); + yield* TestClock.adjust("55 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, "completed"); + assert.equal(publications.at(-1)?.replay, true); + }), + ); + + it.effect("does not mistake two untimestamped completions for the same result", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + for (let run = 0; run < 2; run++) { + yield* Ref.set(currentShell, shell()); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed", latestRunCompletedAt: null })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + } + const completions = publications.filter( + (publication) => publication.state?.phase === "completed", + ); + assert.equal(completions.length, 2); + assert.isTrue(completions.every((publication) => publication.replay === false)); + }), + ); + it.effect("does not alert again when a completed thread is settled then reopened", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index d694556568a9d..f587e2cb355da 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -339,7 +339,7 @@ function terminalWorkSinceStart(thread: OrchestrationV2ThreadShell, startedAt: n export function resolveAgentAwarenessRelayActiveThreadIds(input: { readonly environmentId: EnvironmentId; - readonly startedAt: number; + readonly now: number; readonly projects: ReadonlyArray>; readonly threads: ReadonlyArray; }): ReadonlyArray { @@ -354,7 +354,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { environmentId: input.environmentId, project, thread, - now: input.startedAt, + now: input.now, }); return state !== null; }) @@ -441,6 +441,33 @@ export const make = Effect.gen(function* () { yield* Ref.set(publishedStateByThreadRef, new Map()); }); + const snoozeWakeups = new Map }>(); + let enqueueSnoozeWakeup: (threadId: ThreadId) => Effect.Effect = () => Effect.void; + const scheduleSnoozeWakeup = Effect.fn("AgentAwarenessRelay.scheduleSnoozeWakeup")(function* ( + thread: OrchestrationV2ThreadShell, + nowMs: number, + ) { + const deadline = thread.snoozedUntil?.epochMilliseconds; + const previous = snoozeWakeups.get(thread.id); + const pending = deadline !== undefined && deadline > nowMs && thread.archivedAt === null; + if (pending && previous?.deadline === deadline) return; + snoozeWakeups.delete(thread.id); + if (previous) yield* Fiber.interrupt(previous.timer); + if (!pending) return; + // Expiry changes the projection without a domain event; startup must restore these timers too. + const timer = yield* Effect.sleep(deadline - nowMs).pipe( + Effect.andThen( + Effect.suspend(() => { + if (snoozeWakeups.get(thread.id)?.deadline !== deadline) return Effect.void; + snoozeWakeups.delete(thread.id); + return enqueueSnoozeWakeup(thread.id); + }), + ), + Effect.forkIn(scope), + ); + snoozeWakeups.set(thread.id, { deadline, timer }); + }); + const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { const publishAgentActivity = yield* readPublishAgentActivityEnabled; if (!publishAgentActivity) { @@ -523,6 +550,9 @@ export const make = Effect.gen(function* () { // Read only this thread; full shell reads would make each activity event scale with all threads. const threadShell = yield* threads.getThreadShell(threadId); + if (threadShell !== null) { + yield* scheduleSnoozeWakeup(threadShell, (yield* DateTime.now).epochMilliseconds); + } if ( threadShell?.lineage.relationshipToParent === "subagent" && !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) @@ -548,6 +578,7 @@ export const make = Effect.gen(function* () { const previous = publishedStateByThread.get(threadId); const previousCompletion = publishedCompletions.get(threadId); const sameCompletion = + snapshot.state?.completedAt !== undefined && previousCompletion?.phase === snapshot.state?.phase && previousCompletion?.completedAt === snapshot.state?.completedAt; const replay = @@ -662,6 +693,7 @@ export const make = Effect.gen(function* () { const worker = yield* makeAgentAwarenessPublishWorker(processThreadPublish); const enqueueThreadPublish = (threadId: ThreadId) => cancelPublishRetry(threadId).pipe(Effect.andThen(worker.enqueue(threadId))); + enqueueSnoozeWakeup = enqueueThreadPublish; const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => enqueueThreadPublish(threadId).pipe(Effect.andThen(worker.drain)); @@ -720,9 +752,13 @@ export const make = Effect.gen(function* () { projects.snapshot, threads.getShellSnapshot(), ]); + const nowMs = (yield* DateTime.now).epochMilliseconds; + yield* Effect.forEach(shellSnapshot.threads, (thread) => scheduleSnoozeWakeup(thread, nowMs), { + discard: true, + }); const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, - startedAt, + now: nowMs, projects: projectSnapshot.projects, threads: shellSnapshot.threads, }); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 51710d5c301e0..04adb20560b99 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -180,6 +180,80 @@ describe("AgentActivityPublisher", () => { ), ); }); + it.effect("notifies an off-card completion without changing the five display rows", () => { + const completed: RelayAgentActivityState = { + ...state, + phase: "completed", + unread: true, + retained: true, + }; + const failures = Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: `failure-${i}` as RelayAgentActivityState["threadId"], + phase: "failed" as const, + unread: true, + retained: true, + })); + const cards: Array[0]> = + []; + const notifications: Array< + Parameters[0] + > = []; + return Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + const input = { + environmentId: state.environmentId, + environmentPublicKey: "key", + threadId: state.threadId, + state: completed, + }; + yield* publisher.publish(input); + expect(cards[0]?.aggregate?.activities).toHaveLength(5); + expect(cards[0]?.aggregate?.activities.some((row) => row.threadId === state.threadId)).toBe( + false, + ); + expect(notifications).toHaveLength(1); + expect(notifications[0]?.aggregate?.activities[0]?.threadId).toBe(state.threadId); + yield* publisher.publish({ ...input, replay: true }); + expect(notifications).toHaveLength(1); + }).pipe( + Effect.provide( + layerPublisher.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ + listForUser: () => Effect.succeed([...failures, completed]), + }), + ), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ listTargets: () => Effect.succeed([target("ios")]) }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + cards.push(input); + return null; + }), + sendPushNotificationForTarget: (input) => + Effect.sync(() => { + notifications.push(input); + return null; + }), + }), + ), + ), + ), + ), + ), + ); + }); + it.effect("routes Android publication and registration replay to FCM alongside iOS", () => { const android = { ...target("android"), platform: "android" as const, ios_major_version: null }; const ios = target("ios"); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 7b9a7a1cb47ab..a89528dd47a05 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -78,8 +78,13 @@ const make = Effect.gen(function* () { const notificationOnlyAggregate = !input.replay && input.deliveryUser.notificationsEnabled && - !input.deliveryUser.liveActivitiesEnabled && - input.state !== null + input.state !== null && + (!input.deliveryUser.liveActivitiesEnabled || + !liveActivityAggregate?.activities.some( + (row) => + row.environmentId === input.state?.environmentId && + row.threadId === input.state.threadId, + )) ? makeAggregateState({ activeStates: isTerminalPhase(input.state) ? [] : [input.state], terminalState: isTerminalPhase(input.state) ? input.state : null, diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 689ded28a3e10..cb456b56a2dba 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -268,6 +268,57 @@ describe("Android delivery routing", () => { }, ); + it.effect("retains an unread result without an automatic Android expiry", () => { + const h = harness(); + h.current.state = { ...state, phase: "completed", unread: true, retained: true }; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ active: "true", activity_expires_at: "0" }); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("uses running threads outside the five display rows to set Android expiry", () => { + const h = harness(); + h.current.otherStates = Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`failure-${i}`), + threadTitle: `Old failure ${i}`, + phase: "failed" as const, + unread: false, + retained: true, + })); + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process(h.job); + expect(h.sent[0]?.data.activity_expires_at).toBe(String(2 * 60 * 60_000)); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("alerts a new result outside the five visible rows and keeps replays silent", () => { + const h = harness(); + h.current.otherStates = Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`failure-${i}`), + threadTitle: `Old failure ${i}`, + phase: "failed" as const, + unread: true, + retained: true, + })); + h.current.target.last_aggregate_json = encodeJson( + aggregateFor([state, ...h.current.otherStates]), + ); + h.current.state = { ...state, phase: "completed", unread: true, retained: true }; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data.alert_title).toBe(state.threadTitle); + expect(h.sent[0]?.data.activity_line_4).not.toContain(state.threadTitle); + yield* delivery.process({ ...h.job, state: h.current.state, replay: true }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }); + it("expires an Android card immediately after its last result is read", () => { const read = { ...state, phase: "completed" as const, unread: false, retained: true }; expect(androidActivityData(aggregateFor([read]), 60_000)).toMatchObject({ diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index b094a9c02ef71..924c83a9e0972 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -264,17 +264,29 @@ const make = Effect.gen(function* () { allowedEnvironments.add(environmentId); } } - alert = androidAlertForAggregate({ - previousAggregate, - nextAggregate: { - ...aggregate, - activities: aggregate.activities.filter((row) => - allowedEnvironments.has(row.environmentId), - ), - }, - preferences: preferences.value, - nowMs: now.epochMilliseconds, - }); + const hiddenCurrentState = + state?.phase === job.state.phase && + state.updatedAt === job.state.updatedAt && + !aggregate.activities.some( + (row) => row.environmentId === state.environmentId && row.threadId === state.threadId, + ) && + deliveryUser.notificationsEnabled && + !isExpiredAgentActivityState(state, now.epochMilliseconds) + ? state + : null; + alert = hiddenCurrentState + ? androidAlertForState(hiddenCurrentState, preferences.value, now.epochMilliseconds) + : androidAlertForAggregate({ + previousAggregate, + nextAggregate: { + ...aggregate, + activities: aggregate.activities.filter((row) => + allowedEnvironments.has(row.environmentId), + ), + }, + preferences: preferences.value, + nowMs: now.epochMilliseconds, + }); } else if ( deliveryUser?.notificationsEnabled && state?.phase === job.state.phase && @@ -295,7 +307,11 @@ const make = Effect.gen(function* () { device_id: job.deviceId, user_id: job.userId, updated_at: String(now.epochMilliseconds), - ...androidActivityData(displayedAggregate, now.epochMilliseconds), + ...androidActivityData( + displayedAggregate, + now.epochMilliseconds, + displayedAggregate ? states : [], + ), ...alert, }; if (alert) { diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 6131a282a5482..1c5f2fd4f038c 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -1,4 +1,7 @@ -import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import type { + RelayAgentActivityAggregateState, + RelayAgentActivityState, +} from "@t3tools/contracts/relay"; import { activityPhasePriority, TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, @@ -8,6 +11,7 @@ import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; export function androidActivityData( aggregate: RelayAgentActivityAggregateState | null, nowMs = Date.parse(aggregate?.updatedAt ?? ""), + lifecycleStates?: ReadonlyArray, ) { const rows = [...(aggregate?.activities ?? [])].sort( (a, b) => @@ -44,15 +48,22 @@ export function androidActivityData( : failed ? "Agent work failed" : "All caught up"; + const pendingResult = lifecycleStates?.some( + (state) => + state.retained === true && + state.unread !== false && + (state.phase === "completed" || state.phase === "failed"), + ); const expiresAt = - aggregate?.pendingCount === 0 + aggregate?.pendingCount === 0 || pendingResult ? 0 : Math.max( 0, - ...rows.map((row) => + ...(lifecycleStates ?? rows).map((row) => row.phase === "completed" || row.phase === "failed" - ? (aggregate?.pendingCount === undefined ? Date.parse(row.updatedAt) : nowMs) + - TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS + ? "retained" in row && row.retained === true + ? 0 + : Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS : agentActivityExpiresAt(row), ), ); From 89473adeb581099dbcfad07c486348fc53ae2320 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 27 Oct 2026 23:34:42 -0400 Subject: [PATCH 04/18] fix(mobile): preserve approval alerts during off-card updates --- .../AgentNotifications.kt | 5 ++- .../AgentNotificationsTest.kt | 24 +++++++++----- .../src/agentActivity/FcmDeliveries.test.ts | 32 +++++++++++++++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 28 ++++++++-------- 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index 77ccf3a3d4ebe..6f2ea6fae3b73 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -228,8 +228,11 @@ object AgentNotifications { ?: if (active) updatedAt + RUNNING_LIFETIME_MS else 0L // A retained unread result has no timeout; zero on an inactive payload still clears the card. val retained = active && expiresAt == 0L - val remainingMs = if (retained) 0L else + val remainingMs = if (retained) { + 0L + } else { (expiresAt - System.currentTimeMillis()).coerceAtMost(MAX_LIFETIME_MS) + } val wasActive = prefs.getBoolean("lastActive", false) prefs.edit().putBoolean("lastActive", active).apply() if ((!retained && remainingMs <= 0) || !prefs.getBoolean("ongoing", false)) { diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index 001cd804e1b35..bb38aa92c9495 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -69,18 +69,26 @@ class AgentNotificationsTest { @Test fun retainedUnreadResultHasNoTimeoutAndReadingItClearsTheCard() { - AgentNotifications.receive(context, update("done", true) + mapOf( - "activity_phase" to "completed", - "activity_expires_at" to "0", - )) + AgentNotifications.receive( + context, + update("done", true) + mapOf( + "activity_phase" to "completed", + "activity_expires_at" to "0", + ) + ) val card = manager.activeNotifications.single().notification if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) assertEquals(0L, card.timeoutAfter) - assertTrue(shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty()) + assertTrue( + shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty() + ) AgentNotifications.expire(context, System.currentTimeMillis() + 24 * 60 * 60 * 1000L) assertEquals(1, manager.activeNotifications.size) - AgentNotifications.receive(context, update("done-read", false) + mapOf( - "activity_expires_at" to "0", - )) + AgentNotifications.receive( + context, + update("done-read", false) + mapOf( + "activity_expires_at" to "0", + ) + ) assertEquals(0, manager.activeNotifications.size) } diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index cb456b56a2dba..38b9abed8fc49 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -268,6 +268,38 @@ describe("Android delivery routing", () => { }, ); + it.effect.each(["running", "completed"] as const)( + "an off-card %s update cannot consume a visible approval alert", + (phase) => { + const h = harness(); + const failures = Array.from({ length: 5 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`failure-${i}`), + phase: "failed" as const, + unread: false, + retained: true, + })); + h.current.target.preferences_json = encodeJson({ ...preferences, notifyOnCompletion: false }); + h.current.otherStates = [...failures, secondState]; + h.current.target.last_aggregate_json = encodeJson( + aggregateFor([state, ...h.current.otherStates]), + ); + h.current.state = { ...state, phase, unread: true, retained: true }; + const approval = { ...secondState, phase: "waiting_for_approval" as const }; + h.current.otherStates = [...failures, approval]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + alert_title: secondState.threadTitle, + alert_body: "Approval: Project", + }); + yield* delivery.process({ ...h.job, state: approval }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(1); + }).pipe(Effect.provide(h.layer)); + }, + ); + it.effect("retains an unread result without an automatic Android expiry", () => { const h = harness(); h.current.state = { ...state, phase: "completed", unread: true, retained: true }; diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 924c83a9e0972..89acea8fbd855 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -274,19 +274,21 @@ const make = Effect.gen(function* () { !isExpiredAgentActivityState(state, now.epochMilliseconds) ? state : null; - alert = hiddenCurrentState - ? androidAlertForState(hiddenCurrentState, preferences.value, now.epochMilliseconds) - : androidAlertForAggregate({ - previousAggregate, - nextAggregate: { - ...aggregate, - activities: aggregate.activities.filter((row) => - allowedEnvironments.has(row.environmentId), - ), - }, - preferences: preferences.value, - nowMs: now.epochMilliseconds, - }); + alert = + androidAlertForAggregate({ + previousAggregate, + nextAggregate: { + ...aggregate, + activities: aggregate.activities.filter((row) => + allowedEnvironments.has(row.environmentId), + ), + }, + preferences: preferences.value, + nowMs: now.epochMilliseconds, + }) ?? + (hiddenCurrentState + ? androidAlertForState(hiddenCurrentState, preferences.value, now.epochMilliseconds) + : null); } else if ( deliveryUser?.notificationsEnabled && state?.phase === job.state.phase && From 4be6296cbc1ac5c83b03c818f13ee9e183be5ae7 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Tue, 27 Oct 2026 23:42:39 -0400 Subject: [PATCH 05/18] fix(mobile): acknowledge replayed threads without consuming other alerts --- .../AgentActivityLifecycleTest.kt | 109 ++++++++++++++++++ .../AgentNotificationsTest.kt | 42 ------- .../src/agentActivity/FcmDeliveries.test.ts | 44 ++++++- .../relay/src/agentActivity/FcmDeliveries.ts | 34 +++++- 4 files changed, 185 insertions(+), 44 deletions(-) create mode 100644 apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityLifecycleTest.kt diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityLifecycleTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityLifecycleTest.kt new file mode 100644 index 0000000000000..3f2c2ad600f22 --- /dev/null +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityLifecycleTest.kt @@ -0,0 +1,109 @@ +package expo.modules.t3agentnotifications + +import android.app.Activity +import android.app.AlarmManager +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ProcessLifecycleOwner +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 26, 33, 36], manifest = Config.NONE) +class AgentActivityLifecycleTest { + private lateinit var context: Application + private lateinit var manager: NotificationManager + private lateinit var lifecycle: LifecycleRegistry + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + manager = context.getSystemService(NotificationManager::class.java) + shadowOf(manager).setNotificationsEnabled(true) + lifecycle = ProcessLifecycleOwner.get().lifecycle as LifecycleRegistry + lifecycle.currentState = Lifecycle.State.CREATED + + val launcher = ComponentName(context, Activity::class.java) + shadowOf(context.packageManager).addActivityIfNotPresent(launcher) + shadowOf(context.packageManager).addIntentFilterForActivity( + launcher, + IntentFilter(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + } + ) + AgentNotifications.clear(context) + AgentNotifications.configure(context, "device", "user", "t3code-dev", true) + AgentNotifications.setThreadOnScreen("/threads/environment/thread") + } + + private fun update(alertId: String, active: Boolean) = mapOf( + "device_id" to "device", + "user_id" to "user", + "updated_at" to System.currentTimeMillis().toString(), + "active" to active.toString(), + "activity_title" to "1 active agent", + "activity_body" to "Test thread · Working", + "activity_path" to "/threads/environment/thread", + "alert_id" to alertId, + "alert_group" to "environment/thread", + "alert_title" to "Test thread", + "alert_body" to "Done: Test project", + "alert_path" to "/threads/environment/thread", + ) + + @Test + fun retainedUnreadResultHasNoTimeoutAndReadingItClearsTheCard() { + AgentNotifications.receive( + context, + update("done", true) + mapOf( + "activity_phase" to "completed", + "activity_expires_at" to "0", + ) + ) + val card = manager.activeNotifications.single().notification + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) assertEquals(0L, card.timeoutAfter) + assertTrue( + shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty() + ) + AgentNotifications.expire(context, System.currentTimeMillis() + 24 * 60 * 60 * 1000L) + assertEquals(1, manager.activeNotifications.size) + AgentNotifications.receive( + context, + update("done-read", false) + mapOf( + "activity_expires_at" to "0", + ) + ) + assertEquals(0, manager.activeNotifications.size) + } + + @Test + fun waitingThreadsShowTheirCountInsteadOfZeroWorking() { + lifecycle.currentState = Lifecycle.State.RESUMED + val data = update("waiting-only", true) + mapOf( + "activity_line_0" to "Input\tChoose a model\tProject", + "activity_line_1" to "Input\tChoose an icon\tProject", + "activity_phase" to "waiting_for_input", + "activity_active_count" to "2", + "activity_attention_count" to "2", + "activity_working_count" to "0", + "activity_total_count" to "2", + ) + AgentNotifications.receive(context, data) + val card = manager.activeNotifications.single().notification + assertEquals("Project · 2 threads", card.extras.getString(Notification.EXTRA_SUB_TEXT)) + } +} diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index bb38aa92c9495..aa4ad386a0d35 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -67,31 +67,6 @@ class AgentNotificationsTest { "alert_path" to "/threads/environment/thread", ) - @Test - fun retainedUnreadResultHasNoTimeoutAndReadingItClearsTheCard() { - AgentNotifications.receive( - context, - update("done", true) + mapOf( - "activity_phase" to "completed", - "activity_expires_at" to "0", - ) - ) - val card = manager.activeNotifications.single().notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) assertEquals(0L, card.timeoutAfter) - assertTrue( - shadowOf(context.getSystemService(AlarmManager::class.java)).scheduledAlarms.isEmpty() - ) - AgentNotifications.expire(context, System.currentTimeMillis() + 24 * 60 * 60 * 1000L) - assertEquals(1, manager.activeNotifications.size) - AgentNotifications.receive( - context, - update("done-read", false) + mapOf( - "activity_expires_at" to "0", - ) - ) - assertEquals(0, manager.activeNotifications.size) - } - @Test fun alertHistoryEvictsOnlyTheOldestEntryAfterCapacity() { lifecycle.currentState = Lifecycle.State.RESUMED @@ -489,23 +464,6 @@ class AgentNotificationsTest { assertFalse(card.extras.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER)) } - @Test - fun waitingThreadsShowTheirCountInsteadOfZeroWorking() { - lifecycle.currentState = Lifecycle.State.RESUMED - val data = update("waiting-only", true) + mapOf( - "activity_line_0" to "Input\tChoose a model\tProject", - "activity_line_1" to "Input\tChoose an icon\tProject", - "activity_phase" to "waiting_for_input", - "activity_active_count" to "2", - "activity_attention_count" to "2", - "activity_working_count" to "0", - "activity_total_count" to "2", - ) - AgentNotifications.receive(context, data) - val card = manager.activeNotifications.single().notification - assertEquals("Project · 2 threads", card.extras.getString(Notification.EXTRA_SUB_TEXT)) - } - @Test fun multipleAgentsUseTheChipCountAndFinishedThreadsRemainInTheSummary() { lifecycle.currentState = Lifecycle.State.RESUMED diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 38b9abed8fc49..88102f0e1a16c 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -26,6 +26,7 @@ const aggregateFor = (states: ReadonlyArray) => makeAggregateState({ activeStates: states, terminalState: null, nowMs: 0 })!; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const state: RelayAgentActivityState = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread"), @@ -261,13 +262,54 @@ describe("Android delivery routing", () => { h.current.otherStates = [{ ...secondState, phase: "completed", unread: true }]; const baseline = h.current.target.last_aggregate_json; yield* delivery.process({ ...h.job, replay: true }); - expect(h.current.target.last_aggregate_json).toBe(baseline); + expect(decodeJson(h.current.target.last_aggregate_json)).toEqual(decodeJson(baseline)); yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); expect(h.sent.filter((message) => message.alert)).toHaveLength(1); }).pipe(Effect.provide(h.layer)); }, ); + it.effect( + "a replay adds a missing baseline row without acknowledging another pending result", + () => { + const h = harness(); + h.current.target.last_aggregate_json = encodeJson(aggregateFor([secondState])); + h.current.state = { ...state, phase: "completed", unread: true, retained: true }; + h.current.otherStates = [ + { ...secondState, phase: "completed", unread: true, retained: true }, + ]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state, replay: true }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data.alert_title).toBe(secondState.threadTitle); + }).pipe(Effect.provide(h.layer)); + }, + ); + + it.effect.each(["completed", "waiting_for_approval"] as const)( + "a replayed %s row does not alert on the next ordinary job", + (phase) => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process(h.job); + h.current.state = { ...state, phase, unread: true, retained: true }; + yield* delivery.process({ ...h.job, state: h.current.state, replay: true }); + yield* delivery.process({ ...h.job, state: secondState }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(0); + h.current.otherStates = [{ ...secondState, phase: "completed", unread: true }]; + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data.alert_title).toBe(secondState.threadTitle); + }).pipe(Effect.provide(h.layer)); + }, + ); + it.effect.each(["running", "completed"] as const)( "an off-card %s update cannot consume a visible approval alert", (phase) => { diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 89acea8fbd855..0d7ab5adb13b7 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -298,6 +298,38 @@ const make = Effect.gen(function* () { alert = androidAlertForState(state, preferences.value, now.epochMilliseconds); } } + let deliveryBaseline = aggregate; + if (job.replay && job.state) { + const replayedAggregate = makeAggregateState({ + activeStates: states.filter( + (state) => + state.environmentId === job.state?.environmentId && + state.threadId === job.state.threadId && + state.phase === job.state.phase && + state.updatedAt === job.state.updatedAt, + ), + terminalState: null, + nowMs: now.epochMilliseconds, + }); + const replayedRow = replayedAggregate?.activities[0]; + if (replayedAggregate && replayedRow) { + // A silent thread replay acknowledges only itself, preserving other pending transitions. + const previousRows = previousAggregate?.activities ?? []; + const index = previousRows.findIndex( + (row) => + row.environmentId === replayedRow.environmentId && + row.threadId === replayedRow.threadId, + ); + deliveryBaseline = { + ...(previousAggregate ?? replayedAggregate), + activities: + index === -1 + ? [...previousRows, replayedRow] + : previousRows.toSpliced(index, 1, replayedRow), + }; + acknowledgeAggregate = true; + } + } const displayedAggregate = preferences.value.notificationsEnabled && preferences.value.liveActivitiesEnabled ? aggregate @@ -349,7 +381,7 @@ const make = Effect.gen(function* () { deviceId: job.deviceId, kind: active ? "live_activity_update" : "live_activity_end", // Retain terminal rows as the baseline so finished-card replays cannot alert again. - aggregate: preferences.value.liveActivitiesEnabled ? aggregate : null, + aggregate: preferences.value.liveActivitiesEnabled ? deliveryBaseline : null, deliveredAt: DateTime.formatIso(now), }); } From cb5649b45ec4e5f130ea224ade82c54276c8078e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 09:00:31 -0400 Subject: [PATCH 06/18] test(mobile): suppress alerts in retained activity lifecycle case --- .../modules/t3agentnotifications/AgentActivityLifecycleTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityLifecycleTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityLifecycleTest.kt index 3f2c2ad600f22..68558c8c1a30a 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityLifecycleTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentActivityLifecycleTest.kt @@ -67,6 +67,7 @@ class AgentActivityLifecycleTest { @Test fun retainedUnreadResultHasNoTimeoutAndReadingItClearsTheCard() { + lifecycle.currentState = Lifecycle.State.RESUMED AgentNotifications.receive( context, update("done", true) + mapOf( From b408f9dcd3c735e783fb1fb0479f3e396d050344 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 10:06:43 -0400 Subject: [PATCH 07/18] fix(mobile): retain unacknowledged snooze wakes in agent activity --- .../AgentActivityPresentation.kt | 4 +- .../AgentNotificationsTest.kt | 9 ++- .../showcase/showcaseAgentActivity.ts | 1 + apps/mobile/src/widgets/AgentActivity.tsx | 30 +++++++-- .../src/relay/AgentAwarenessRelay.test.ts | 10 +-- .../relay/src/agentActivity/ApnsDeliveries.ts | 5 +- .../src/agentActivity/FcmDeliveries.test.ts | 27 +++++--- .../agentActivity/agentActivityAggregate.ts | 12 +++- .../agentActivity/agentActivityPayloads.ts | 1 + .../agentActivity/agentActivityPolicy.test.ts | 24 +++++-- infra/relay/src/agentActivity/fcmPayloads.ts | 4 +- packages/contracts/src/relay.ts | 1 + packages/shared/src/agentAwareness.test.ts | 62 ++++++++++++------- packages/shared/src/agentAwareness.ts | 19 +++++- 14 files changed, 150 insertions(+), 59 deletions(-) diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt index 1c64692996fbb..4bc5e0acfb8eb 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentActivityPresentation.kt @@ -34,6 +34,7 @@ internal enum class ActivityPhase( "Answer", R.color.agent_activity_input ), + WOKE("Woke", "Snooze ended", "Woke", "Open", R.color.agent_activity_attention), STALE( "Waiting", "Waiting for an update", @@ -56,7 +57,7 @@ internal enum class ActivityPhase( R.color.agent_activity_failed ); - val needsUser get() = this == APPROVAL || this == INPUT + val needsUser get() = this == APPROVAL || this == INPUT || this == WOKE val finished get() = this == COMPLETED || this == FAILED companion object { @@ -80,6 +81,7 @@ internal fun activityPhase(data: Map, rows: List): "running" -> ActivityPhase.RUNNING "waiting_for_approval" -> ActivityPhase.APPROVAL "waiting_for_input" -> ActivityPhase.INPUT + "woke" -> ActivityPhase.WOKE "stale" -> ActivityPhase.STALE "completed" -> ActivityPhase.COMPLETED "failed" -> ActivityPhase.FAILED diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index aa4ad386a0d35..83239faa39bb5 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -380,8 +380,12 @@ class AgentNotificationsTest { fun waitingCardsIgnoreMutableThreadTimestampsEvenAfterRename() { lifecycle.currentState = Lifecycle.State.RESUMED val now = System.currentTimeMillis() - for (phase in listOf("waiting_for_approval", "waiting_for_input")) { - val status = if (phase == "waiting_for_approval") "Approval" else "Input" + for (phase in listOf("waiting_for_approval", "waiting_for_input", "woke")) { + val status = when (phase) { + "waiting_for_approval" -> "Approval" + "woke" -> "Woke" + else -> "Input" + } for ((title, updatedAt) in listOf("Original" to now - 1200000L, "Renamed" to now)) { AgentNotifications.receive( context, @@ -422,6 +426,7 @@ class AgentNotificationsTest { for (phase in listOf( "waiting_for_approval", "waiting_for_input", + "woke", "stale", "failed", "completed" diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts index 784875fd6253b..c936c90d5baa6 100644 --- a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -26,6 +26,7 @@ const STATUS_BY_PHASE: Record = { waiting_for_approval: "Approval", waiting_for_input: "Input", stale: "Waiting", + woke: "Woke", completed: "Done", failed: "Failed", }; diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index b75c19dcd712e..2e03835d9154c 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -31,7 +31,8 @@ export type AgentActivityPhase = | "waiting_for_input" | "completed" | "failed" - | "stale"; + | "stale" + | "woke"; export interface AgentActivityRowProps { readonly environmentId: string; @@ -92,7 +93,10 @@ function AgentActivity( ? props.attentionCount : countsComplete ? receivedActivities.filter( - (row) => row.phase === "waiting_for_input" || row.phase === "waiting_for_approval", + (row) => + row.phase === "waiting_for_input" || + row.phase === "waiting_for_approval" || + row.phase === "woke", ).length : null; const totalCount = @@ -126,6 +130,7 @@ function AgentActivity( switch (phase) { case "waiting_for_approval": case "waiting_for_input": + case "woke": return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300 case "failed": return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300 @@ -141,7 +146,12 @@ function AgentActivity( }; const phasePriority = (row: AgentActivityRowProps): number => { - if (row.phase === "waiting_for_approval" || row.phase === "waiting_for_input") return 0; + if ( + row.phase === "waiting_for_approval" || + row.phase === "waiting_for_input" || + row.phase === "woke" + ) + return 0; if (row.phase === "failed") return 1; if (row.phase === "completed" && row.unread !== false) return 2; if (row.phase === "running" || row.phase === "starting") return 3; @@ -153,7 +163,7 @@ function AgentActivity( const isSystemStale = "isStale" in environment && environment.isStale === true; const activities: ReadonlyArray = isSystemStale ? receivedActivities.map((row) => - row.phase === "completed" || row.phase === "failed" + row.phase === "completed" || row.phase === "failed" || row.phase === "woke" ? row : { ...row, phase: "stale", status: "Out of date" }, ) @@ -172,7 +182,10 @@ function AgentActivity( const row4 = ordered[4]; const attentionRows = activities.filter( - (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + (row) => + row.phase === "waiting_for_approval" || + row.phase === "waiting_for_input" || + row.phase === "woke", ); const attentionRow = attentionRows[0]; const failedRow = activities.find((row) => row.phase === "failed"); @@ -251,6 +264,8 @@ function AgentActivity( return "exclamationmark.circle.fill"; case "waiting_for_input": return "questionmark.circle.fill"; + case "woke": + return "alarm.fill"; case "failed": return "xmark.octagon.fill"; case "completed": @@ -287,6 +302,7 @@ function AgentActivity( const isRead = (row: AgentActivityRowProps) => row.phase === "completed" && row.unread === false; const isEmphasized = (row: AgentActivityRowProps) => + row.phase === "woke" || row.phase === "waiting_for_input" || row.phase === "waiting_for_approval" || (row.phase === "completed" && row.unread !== false); @@ -496,7 +512,9 @@ function AgentActivity( {attentionRow ? attentionRow.phase === "waiting_for_approval" ? "Approval" - : "Input" + : attentionRow.phase === "woke" + ? "Woke" + : "Input" : failedRow ? "Failed" : doneSuffix || activeLabel} diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9bf969e47d510..12a5aaed61826 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -584,8 +584,8 @@ describe("AgentAwarenessRelay", () => { assert.equal(publications.length, 0); yield* TestClock.adjust("59 seconds"); yield* relay.drain; - assert.equal(publications.at(-1)?.state?.phase, "completed"); - assert.equal(publications.at(-1)?.replay, true); + assert.equal(publications.at(-1)?.state?.phase, "woke"); + assert.equal(publications.at(-1)?.replay, false); }).pipe(Effect.scoped), ); @@ -607,7 +607,7 @@ describe("AgentAwarenessRelay", () => { assert.equal(publications.length, before); yield* TestClock.adjust("60 seconds"); yield* relay.drain; - assert.equal(publications.at(-1)?.state?.phase, "completed"); + assert.equal(publications.at(-1)?.state?.phase, "woke"); }), ); @@ -628,8 +628,8 @@ describe("AgentAwarenessRelay", () => { assert.equal(publications.at(-1)?.state, null); yield* TestClock.adjust("55 seconds"); yield* relay.drain; - assert.equal(publications.at(-1)?.state?.phase, "completed"); - assert.equal(publications.at(-1)?.replay, true); + assert.equal(publications.at(-1)?.state?.phase, "woke"); + assert.equal(publications.at(-1)?.replay, false); }), ); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index df01374f62897..8d2be3660ab5b 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -144,7 +144,10 @@ function parsePreferences(value: string): RelayAgentAwarenessPreferences | null function aggregateNeedsAttention(aggregate: RelayAgentActivityAggregateState): boolean { return aggregate.activities.some( - (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + (row) => + row.phase === "waiting_for_approval" || + row.phase === "waiting_for_input" || + row.phase === "woke", ); } diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 88102f0e1a16c..da638ff9a7eb6 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -342,15 +342,24 @@ describe("Android delivery routing", () => { }, ); - it.effect("retains an unread result without an automatic Android expiry", () => { - const h = harness(); - h.current.state = { ...state, phase: "completed", unread: true, retained: true }; - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.process({ ...h.job, state: h.current.state }); - expect(h.sent[0]?.data).toMatchObject({ active: "true", activity_expires_at: "0" }); - }).pipe(Effect.provide(h.layer)); - }); + it.effect.each(["completed", "woke"] as const)( + "retains a pending %s row without an automatic Android expiry", + (phase) => { + const h = harness(); + h.current.state = { ...state, phase, unread: phase === "completed", retained: true }; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data).toMatchObject({ + active: "true", + activity_expires_at: "0", + activity_working_count: "0", + activity_attention_count: phase === "woke" ? "1" : "0", + }); + expect(h.sent.filter((message) => message.alert)).toHaveLength(phase === "woke" ? 0 : 1); + }).pipe(Effect.provide(h.layer)); + }, + ); it.effect("uses running threads outside the five display rows to set Android expiry", () => { const h = harness(); diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts index 61d1234cf6119..6493936010fde 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -28,6 +28,8 @@ export function statusForPhase(phase: RelayAgentActivityState["phase"]): string return "Working"; case "stale": return "Waiting"; + case "woke": + return "Woke"; } } @@ -105,12 +107,15 @@ export function makeAggregateState(input: { workingCount: states.filter((state) => state.phase === "running" || state.phase === "starting") .length, attentionCount: states.filter( - (state) => state.phase === "waiting_for_input" || state.phase === "waiting_for_approval", + (state) => + state.phase === "waiting_for_input" || + state.phase === "waiting_for_approval" || + state.phase === "woke", ).length, unreadCount, // Legacy publishers cannot report reads; keep their existing completion lifecycle. ...(states.every((state) => state.unread !== undefined) || - states.some((state) => state.unread === true) + states.some((state) => state.unread === true || state.phase === "woke") ? { pendingCount } : {}), totalCount: states.length, @@ -123,7 +128,8 @@ export function activityPhasePriority( phase: RelayAgentActivityState["phase"], unread?: boolean, ): number { - if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0; + if (phase === "waiting_for_approval" || phase === "waiting_for_input" || phase === "woke") + return 0; if (phase === "failed") return 1; if (phase === "completed" && unread !== false) return 2; if (phase === "starting" || phase === "running") return 3; diff --git a/infra/relay/src/agentActivity/agentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts index b2086426a9ec0..eec9bf67a0f22 100644 --- a/infra/relay/src/agentActivity/agentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -37,6 +37,7 @@ export function isExpiredAgentActivityState( state: RelayAgentActivityState, nowMs: number, ): boolean { + if (state.phase === "woke" && state.retained === true) return false; const expiresAt = agentActivityExpiresAt(state); return !Number.isFinite(expiresAt) || nowMs > expiresAt; } diff --git a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts index aa6edaa764a9b..5f6147df5e91d 100644 --- a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts +++ b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts @@ -45,23 +45,26 @@ describe("shared agent activity policy", () => { make("done-new", "completed", true), make("failed", "failed", true), make("input", "waiting_for_input", false), + make("woke", "woke", false), ]; expect(aggregate(states)).toMatchObject({ - activeCount: 2, + activeCount: 3, workingCount: 1, - attentionCount: 1, + attentionCount: 2, unreadCount: 2, - pendingCount: 5, - totalCount: 6, + pendingCount: 6, + totalCount: 7, activities: [ { threadId: "input" }, + { threadId: "woke", status: "Woke" }, { threadId: "failed" }, { threadId: "done-new" }, { threadId: "done-old" }, - { threadId: "working" }, ], }); - expect(aggregate(states.filter((s) => s.threadId !== "done-old"))).toMatchObject({ + expect( + aggregate(states.filter((s) => s.threadId !== "done-old" && s.threadId !== "woke")), + ).toMatchObject({ totalCount: 5, activities: [ { threadId: "input" }, @@ -98,6 +101,15 @@ describe("shared agent activity policy", () => { ); it("does not notify for already read results or marking a result unread again", () => { + expect(shouldAlertForActivity({ ...state, phase: "woke", preferences, nowMs: 0 })).toBe(false); + const woke = { ...state, phase: "woke" as const, retained: true }; + expect( + makeAggregateState({ + activeStates: [woke], + terminalState: null, + nowMs: 2 * 24 * 60 * 60_000, + }), + ).toMatchObject({ workingCount: 0, attentionCount: 1, pendingCount: 1 }); const done = { ...state, phase: "completed" as const, unread: false }; expect(shouldAlertForActivity({ ...done, preferences, nowMs: 0 })).toBe(false); expect( diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 1c5f2fd4f038c..7daa1af68e2e8 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -51,8 +51,8 @@ export function androidActivityData( const pendingResult = lifecycleStates?.some( (state) => state.retained === true && - state.unread !== false && - (state.phase === "completed" || state.phase === "failed"), + (state.phase === "woke" || + (state.unread !== false && (state.phase === "completed" || state.phase === "failed"))), ); const expiresAt = aggregate?.pendingCount === 0 || pendingResult diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 54dce6435d36b..30124283a79de 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -27,6 +27,7 @@ export const RelayAgentAwarenessPhase = Schema.Literals([ "completed", "failed", "stale", + "woke", ]); export type RelayAgentAwarenessPhase = typeof RelayAgentAwarenessPhase.Type; diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 8e7fdb4ce7393..ca9839eeb9457 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -48,28 +48,48 @@ describe("projectThreadAwarenessV2", () => { ...overrides, }); - it("excludes archived and snoozed inbox rows, then restores them on wake", () => { - const base = { - environmentId: "env-1" as EnvironmentId, - project, - now: Date.parse(NOW), - thread: v2Thread({ status: "completed" }), - }; - const snoozedUntil = DateTime.makeUnsafe("2026-05-22T13:00:00.000Z"); - expect( - projectThreadAwarenessV2({ ...base, thread: { ...base.thread, archivedAt: updatedAt } }), - ).toBeNull(); - expect( - projectThreadAwarenessV2({ ...base, thread: { ...base.thread, snoozedUntil } }), - ).toBeNull(); - expect( - projectThreadAwarenessV2({ - ...base, + it.each(["completed", "cancelled", "interrupted", "idle", "queued", "rolled_back"] as const)( + "restores a snoozed %s inbox row until its wake is acknowledged", + (status) => { + const snoozedUntil = DateTime.makeUnsafe("2026-05-22T13:00:00.000Z"); + const base = { + environmentId: "env-1" as EnvironmentId, + project, now: Date.parse("2026-05-22T13:01:00.000Z"), - thread: { ...base.thread, snoozedUntil }, - }), - ).toMatchObject({ phase: "completed", retained: true }); - }); + thread: { ...v2Thread({ status }), snoozedUntil, lastVisitedAt: updatedAt }, + }; + expect(projectThreadAwarenessV2(base)).toMatchObject({ + phase: "woke", + headline: "Snooze ended", + retained: true, + updatedAt: "2026-05-22T13:00:00.000Z", + }); + expect(projectThreadAwarenessV2({ ...base, now: Date.parse(NOW) })).toBeNull(); + expect( + projectThreadAwarenessV2({ ...base, thread: { ...base.thread, archivedAt: updatedAt } }), + ).toBeNull(); + expect( + projectThreadAwarenessV2({ + ...base, + thread: { ...base.thread, settledOverride: "settled" }, + }), + ).toBeNull(); + for (const visit of [snoozedUntil, DateTime.makeUnsafe("2026-05-22T13:01:00.000Z")]) { + expect( + projectThreadAwarenessV2({ ...base, thread: { ...base.thread, lastVisitedAt: visit } }) + ?.phase ?? null, + ).toBe(status === "completed" ? "completed" : null); + } + expect( + projectThreadAwarenessV2({ ...base, thread: { ...base.thread, snoozedUntil: null } }) + ?.phase ?? null, + ).toBe(status === "completed" ? "completed" : null); + expect( + projectThreadAwarenessV2({ ...base, thread: { ...base.thread, lastVisitedAt: null } }) + ?.phase, + ).toBe("woke"); + }, + ); it.each([ { visit: null, unread: false }, diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 5d1aef4858204..95b49111da62c 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -15,7 +15,8 @@ export type AgentAwarenessPhase = | "waiting_for_input" | "completed" | "failed" - | "stale"; + | "stale" + | "woke"; export interface AgentAwarenessState { readonly environmentId: EnvironmentId; @@ -89,7 +90,15 @@ export function projectThreadAwarenessV2( DateTime.toEpochMillis(thread.snoozedAt))); if (thread.pendingRuntimeRequest == null && !failedAfterSnooze) return null; } - const phase = resolveThreadAwarenessPhaseV2(thread); + const snoozeWakeAt = thread.snoozedUntil; + const woke = + input.now !== undefined && + snoozeWakeAt != null && + DateTime.toEpochMillis(snoozeWakeAt) <= input.now && + (thread.lastVisitedAt == null || + DateTime.toEpochMillis(thread.lastVisitedAt) < DateTime.toEpochMillis(snoozeWakeAt)); + const runPhase = resolveThreadAwarenessPhaseV2(thread); + const phase = woke && (runPhase === null || runPhase === "completed") ? "woke" : runPhase; if (phase === null) { return null; } @@ -120,7 +129,9 @@ export function projectThreadAwarenessV2( ...(completedAt == null ? {} : { completedAt: DateTime.formatIso(completedAt) }), ...(detail === undefined ? {} : { detail }), modelTitle: thread.modelSelection.model, - updatedAt: DateTime.formatIso(thread.updatedAt), + updatedAt: DateTime.formatIso( + phase === "woke" && snoozeWakeAt != null ? snoozeWakeAt : thread.updatedAt, + ), deepLink: buildAgentAwarenessDeepLink({ environmentId, threadId: thread.id }), }; } @@ -181,5 +192,7 @@ function headlineForPhase(phase: AgentAwarenessPhase): string { return "Agent failed"; case "stale": return "Update delayed"; + case "woke": + return "Snooze ended"; } } From 8df653b227c0a58a8e463828aca137388c7c1481 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 10:28:33 -0400 Subject: [PATCH 08/18] fix(mobile): let read timestamps shrink on narrow activity cards --- apps/mobile/src/widgets/AgentActivity.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 2e03835d9154c..10692b837a016 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -318,7 +318,7 @@ function AgentActivity( minimumScaleFactor(0.7), multilineTextAlignment("trailing"), // Relative dates accept all proposed width; bound them so they cannot erase the titles. - frame({ width: 136, alignment: "trailing" }), + frame({ maxWidth: 136, alignment: "trailing" }), ]} /> ) : ( From de60a08cb922aa109321414910f10b7c24109a05 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 11:02:50 -0400 Subject: [PATCH 09/18] fix(mobile): link activity to the first visible attention row --- .../mobile/src/widgets/AgentActivity.test.tsx | 77 +++++++++++++++++++ apps/mobile/src/widgets/AgentActivity.tsx | 3 +- 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/widgets/AgentActivity.test.tsx diff --git a/apps/mobile/src/widgets/AgentActivity.test.tsx b/apps/mobile/src/widgets/AgentActivity.test.tsx new file mode 100644 index 0000000000000..2ea4c646f4438 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivity.test.tsx @@ -0,0 +1,77 @@ +import type { LiveActivityComponent } from "expo-widgets"; +import { isValidElement } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import activity, { type AgentActivityProps, type AgentActivityRowProps } from "./AgentActivity.tsx"; + +vi.mock("expo-widgets", () => ({ + createLiveActivity: (_name: string, render: LiveActivityComponent) => render, +})); +vi.mock("@expo/ui/swift-ui", () => ({ + HStack: "HStack", + Image: "Image", + Text: "Text", + Spacer: "Spacer", + VStack: "VStack", + ZStack: "ZStack", +})); +vi.mock("@expo/ui/swift-ui/modifiers", () => + Object.fromEntries( + [ + "accessibilityElement", + "accessibilityLabel", + "activityBackgroundTint", + "aspectRatio", + "font", + "foregroundStyle", + "frame", + "layoutPriority", + "lineLimit", + "minimumScaleFactor", + "multilineTextAlignment", + "padding", + "resizable", + "widgetURL", + ].map((name) => [name, (value: unknown) => ({ name, value })]), + ), +); + +it("targets the first displayed attention thread when incoming rows are unordered", () => { + const render = activity as unknown as LiveActivityComponent; + const older: AgentActivityRowProps = { + environmentId: "environment", + threadId: "older", + projectTitle: "Project", + threadTitle: "Older request", + modelTitle: "Codex", + phase: "waiting_for_approval", + status: "Approval needed", + updatedAt: "2026-10-28T09:00:00Z", + deepLink: "/threads/environment/older", + }; + const newer = { + ...older, + threadId: "newer", + phase: "woke" as const, + updatedAt: "2026-10-28T10:00:00Z", + deepLink: "/threads/environment/newer", + }; + const layout = render( + { + title: "Agents", + subtitle: "", + activeCount: 2, + updatedAt: newer.updatedAt, + activities: [older, newer], + }, + { colorScheme: "dark" }, + ); + const compact = layout.compactTrailing; + expect(isValidElement(compact)).toBe(true); + if (!isValidElement<{ children: string; modifiers: unknown[] }>(compact)) return; + expect(compact.props.children).toBe("Woke"); + expect(compact.props.modifiers).toContainEqual({ + name: "widgetURL", + value: "t3code://threads/environment/newer", + }); +}); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 10692b837a016..be96439b30be2 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -181,13 +181,12 @@ function AgentActivity( const row3 = ordered[3]; const row4 = ordered[4]; - const attentionRows = activities.filter( + const attentionRow = ordered.find( (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input" || row.phase === "woke", ); - const attentionRow = attentionRows[0]; const failedRow = activities.find((row) => row.phase === "failed"); const heroRow = attentionRow ?? failedRow ?? row0; const tint = From be2bcc0e6c4ec5ba08ed653506f537edf3115230 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 11:21:27 -0400 Subject: [PATCH 10/18] test(mobile): cover minimal activity glyph for snooze wakes --- apps/mobile/src/widgets/AgentActivity.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/mobile/src/widgets/AgentActivity.test.tsx b/apps/mobile/src/widgets/AgentActivity.test.tsx index 2ea4c646f4438..f6c81b71d10da 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.tsx +++ b/apps/mobile/src/widgets/AgentActivity.test.tsx @@ -74,4 +74,12 @@ it("targets the first displayed attention thread when incoming rows are unordere name: "widgetURL", value: "t3code://threads/environment/newer", }); + expect(layout.minimal).toMatchObject({ + props: { + children: { props: { systemName: "alarm.fill" } }, + modifiers: expect.arrayContaining([ + { name: "widgetURL", value: "t3code://threads/environment/newer" }, + ]), + }, + }); }); From ae5609252c5e2286d6c73eda95b7dec50fda8c99 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 22:49:36 +0000 Subject: [PATCH 11/18] fix(relay): preserve live activity transitions during replay --- .../src/relay/AgentAwarenessRelay.test.ts | 11 +++++ apps/server/src/relay/AgentAwarenessRelay.ts | 2 +- .../agentActivity/AgentActivityPublisher.ts | 8 ++++ .../src/agentActivity/ApnsDeliveries.test.ts | 40 +++++++++++++++---- .../relay/src/agentActivity/ApnsDeliveries.ts | 21 +++++++++- .../src/agentActivity/ApnsDeliveryQueue.ts | 1 + .../src/agentActivity/apnsDeliveryJobs.ts | 5 +++ packages/shared/src/agentAwareness.test.ts | 8 ++++ packages/shared/src/agentAwareness.ts | 4 +- 9 files changed, 89 insertions(+), 11 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b7bc11ba919a9..48c9990c53c97 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -543,6 +543,17 @@ describe("AgentAwarenessRelay", () => { yield* relay.drain; assert.equal(publications.length, 3); assert.equal(publications[2]?.state, null); + yield* Ref.set(currentShell, terminal); + yield* Queue.offer(events, { + id: EventId.make("event:pinned"), + type: "thread.pinned", + threadId: THREAD_ID, + occurredAt: now, + payload: { ...terminal, lastVisitedAt: null, settledAt: null }, + }); + yield* Queue.take(pulls); + yield* relay.drain; + assert.equal(publications.at(-1)?.state?.phase, status); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 095f830030a29..4ca7647d93aa0 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -97,6 +97,7 @@ function shouldPublishAgentAwarenessEvent( case "thread.settled": case "thread.unsettled": case "thread.snoozed": + case "thread.pinned": case "thread.unsnoozed": case "thread.visited": case "thread.marked-unread": @@ -110,7 +111,6 @@ function shouldPublishAgentAwarenessEvent( return true; case "thread.guest-share-updated": case "thread.auto-settle-set": - case "thread.pinned": case "thread.unpinned": case "thread.pin-reordered": case "thread.active-reordered": diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index a89528dd47a05..455e9903460ae 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -109,6 +109,14 @@ const make = Effect.gen(function* () { apnsDeliveries.sendForTarget({ target, aggregate: liveActivityAggregate, + ...(input.replay && input.state + ? { + replayThread: { + environmentId: input.state.environmentId, + threadId: input.state.threadId, + }, + } + : {}), nowMs: input.nowMs, replay: input.replay ?? false, }), diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 7aff9b4f00980..1e48fed48995a 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1214,19 +1214,20 @@ describe("fast completion delivery", () => { ).toBe(true); }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs, currentTargets: [device] }))); }); - it.effect("replays a newly visible completion without alerting", () => { + it.effect("replays a newly visible completion without consuming another thread's alert", () => { const queuedJobs: SignedApnsDeliveryJob[] = []; + const markedDeliveries: Array< + Parameters[0] + > = []; + const other = { ...aggregate.activities[0]!, threadId: ThreadId.make("other") }; const done = { - ...aggregate, - activeCount: 0, - activities: [{ ...aggregate.activities[0]!, phase: "completed" as const }], - }; - const previous = { ...aggregate, activities: [ - { ...aggregate.activities[0]!, threadId: "other" as RelayAgentActivityState["threadId"] }, + { ...aggregate.activities[0]!, phase: "completed" as const }, + { ...other, phase: "completed" as const }, ], }; + const previous = { ...aggregate, activities: [other] }; const device = { ...target, last_aggregate_json: JSON.stringify(previous) }; return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; @@ -1235,10 +1236,33 @@ describe("fast completion delivery", () => { aggregate: done, nowMs: 0, replay: true, + replayThread: { environmentId: state.environmentId, threadId: state.threadId }, }); expect(queuedJobs).toHaveLength(1); expect(queuedJobs[0]?.payload.alert).toBeUndefined(); - }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs }))); + const result = yield* deliveries.processSignedJob(queuedJobs[0]!); + expect(result.ok).toBe(true); + expect(markedDeliveries[0]?.aggregate?.activities).toEqual([other, done.activities[0]]); + device.last_aggregate_json = JSON.stringify(markedDeliveries[0]?.aggregate); + yield* deliveries.sendForTarget({ target: device, aggregate: done, nowMs: 0 }); + expect(queuedJobs[1]?.payload.alert).toMatchObject({ title: "Thread" }); + }).pipe( + Effect.provide( + layerFor({ + attempts: [], + queuedJobs, + markedDeliveries, + config: signingConfig, + currentTargets: [device], + activityStates: [ + { ...state, phase: "completed" }, + { ...state, threadId: other.threadId, phase: "completed" }, + ], + execute: (request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))), + }), + ), + ); }); }); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 8d2be3660ab5b..86344da9010de 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -34,6 +34,7 @@ import { isApnsDeliveryJobVerificationError, verifySignedApnsDeliveryJob, type ApnsDeliveryJobVerificationError, + type ApnsDeliveryJobPayload, } from "./apnsDeliveryJobs.ts"; import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as DeliveryAttempts from "./DeliveryAttempts.ts"; @@ -439,6 +440,7 @@ interface SendLiveActivityDeliveryInputBase { readonly target: LiveActivityDeliveryTarget; readonly token: string; readonly sourceJobId: string; + readonly replayThread?: ApnsDeliveryJobPayload["replayThread"]; } export type SendLiveActivityDeliveryInput = @@ -498,6 +500,7 @@ export class ApnsDeliveries extends Context.Service< readonly aggregate: RelayAgentActivityAggregateState | null; readonly nowMs: number; readonly replay?: boolean; + readonly replayThread?: ApnsDeliveryJobPayload["replayThread"]; }) => Effect.Effect; readonly sendPushNotificationForTarget: (input: { readonly target: LiveActivities.TargetRow; @@ -766,11 +769,25 @@ const make = Effect.gen(function* () { }), ); if (result.ok) { + let deliveryBaseline = aggregate; + if (input.replayThread && aggregate) { + const matchesReplay = (row: RelayAgentActivityAggregateState["activities"][number]) => + row.environmentId === input.replayThread?.environmentId && + row.threadId === input.replayThread.threadId; + const previous = parseAggregate(currentTarget.last_aggregate_json); + deliveryBaseline = { + ...(previous ?? aggregate), + activities: [ + ...(previous?.activities ?? []).filter((row) => !matchesReplay(row)), + ...aggregate.activities.filter(matchesReplay), + ], + }; + } yield* liveActivities.markDelivery({ userId: input.target.user_id, deviceId: input.target.device_id, kind: input.kind, - aggregate, + aggregate: deliveryBaseline, deliveredAt: iso, }); } else if (isPermanentApnsTokenFailure(result)) { @@ -987,6 +1004,7 @@ const make = Effect.gen(function* () { kind: payload.kind, aggregate: payload.aggregate, alert: payload.alert ?? null, + ...(payload.replayThread ? { replayThread: payload.replayThread } : {}), }); case "live_activity_end": return sendLiveActivity({ @@ -1099,6 +1117,7 @@ const make = Effect.gen(function* () { bundleId: input.target.bundle_id, apsEnvironment: input.target.aps_environment, aggregate: delivery.aggregate, + ...(input.replayThread ? { replayThread: input.replayThread } : {}), alert, }); if (delivery.kind === "live_activity_end" && notification && input.target.push_token) { diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index b14183c9d29b4..bcc79054c4044 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -64,6 +64,7 @@ export class ApnsDeliveryQueue extends Context.Service< readonly apsEnvironment?: "sandbox" | "production" | null; readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; readonly alert?: ApnsDeliveryJobPayload["alert"]; + readonly replayThread?: ApnsDeliveryJobPayload["replayThread"]; }) => Effect.Effect; readonly enqueuePushNotification: (input: { readonly userId: string; diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts index a1fe2aa0bce58..c7965d4631c2a 100644 --- a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -59,6 +59,9 @@ export const ApnsDeliveryJobPayload = Schema.Struct({ aggregate: Schema.NullOr(RelayAgentActivityAggregateState), notification: Schema.NullOr(ApnsNotificationPayload), alert: Schema.optional(Schema.NullOr(ApnsLiveActivityAlert)), + replayThread: Schema.optional( + Schema.Struct({ environmentId: Schema.String, threadId: Schema.String }), + ), createdAt: Schema.String, expiresAt: Schema.String, }); @@ -236,6 +239,7 @@ export function makeApnsDeliveryJobPayload(input: { readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; readonly notification?: ApnsNotificationPayload | null; readonly alert?: ApnsLiveActivityAlert | null | undefined; + readonly replayThread?: ApnsDeliveryJobPayload["replayThread"]; readonly createdAt: string; readonly expiresAt: string; readonly jobId: string; @@ -255,6 +259,7 @@ export function makeApnsDeliveryJobPayload(input: { notification: input.notification ?? null, // Omit absent fields to preserve signatures from older relay builds. ...(input.alert ? { alert: input.alert } : {}), + ...(input.replayThread ? { replayThread: input.replayThread } : {}), createdAt: input.createdAt, expiresAt: input.expiresAt, }; diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 768238e619fc6..8f8a9919141fa 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -74,6 +74,14 @@ describe("projectThreadAwarenessV2", () => { thread: { ...base.thread, settledOverride: "settled" }, }), ).toBeNull(); + if (status === "completed") { + expect( + projectThreadAwarenessV2({ + ...base, + thread: { ...base.thread, latestRunCompletedAt: snoozedUntil }, + }), + ).toMatchObject({ phase: "completed", unread: true }); + } for (const visit of [snoozedUntil, DateTime.makeUnsafe("2026-05-22T13:01:00.000Z")]) { expect( projectThreadAwarenessV2({ ...base, thread: { ...base.thread, lastVisitedAt: visit } }) diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 9724ad79b69ad..af09441dd2a60 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -97,7 +97,9 @@ export function projectThreadAwarenessV2( snoozeWakeAt != null && DateTime.toEpochMillis(snoozeWakeAt) <= input.now && (thread.lastVisitedAt == null || - DateTime.toEpochMillis(thread.lastVisitedAt) < DateTime.toEpochMillis(snoozeWakeAt)); + DateTime.toEpochMillis(thread.lastVisitedAt) < DateTime.toEpochMillis(snoozeWakeAt)) && + (thread.latestRunCompletedAt == null || + DateTime.toEpochMillis(thread.latestRunCompletedAt) < DateTime.toEpochMillis(snoozeWakeAt)); const runPhase = resolveThreadAwarenessPhaseV2(thread); const phase = woke && (runPhase === null || runPhase === "completed") ? "woke" : runPhase; if (phase === null) { From 7619036675f1cee7183857e50589ba2cc6b751e3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 00:19:44 +0000 Subject: [PATCH 12/18] fix(web): acknowledge snooze wakes when viewing threads --- .../ThreadVisitWatermark.browser.test.tsx | 9 +++++-- .../components/chat/ThreadVisitWatermark.tsx | 25 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ThreadVisitWatermark.browser.test.tsx b/apps/web/src/components/chat/ThreadVisitWatermark.browser.test.tsx index a730db051bf0e..ed39ce62114c2 100644 --- a/apps/web/src/components/chat/ThreadVisitWatermark.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadVisitWatermark.browser.test.tsx @@ -37,13 +37,14 @@ const ENVIRONMENT = EnvironmentId.make("environment-visit-watermark"); const threadA: ScopedThreadRef = { environmentId: ENVIRONMENT, threadId: ThreadId.make("a") }; const threadB: ScopedThreadRef = { environmentId: ENVIRONMENT, threadId: ThreadId.make("b") }; -function setShell(ref: ScopedThreadRef, updatedAt: string) { +function setShell(ref: ScopedThreadRef, updatedAt: string, snoozedUntil: string | null = null) { appAtomRegistry.set(shellAtom(shellKey(ref)), { environmentId: ref.environmentId, id: ref.threadId, updatedAt, lastVisitedAt: "2026-01-01T00:00:00.000Z", latestRun: null, + snoozedUntil, }); } @@ -57,7 +58,8 @@ it("records the visit of the next thread at once after switching threads", async mocks.visitRun.mockReset(); mocks.visitRun.mockResolvedValue(AsyncResult.success(undefined)); setShell(threadA, "2026-01-02T00:00:00.000Z"); - setShell(threadB, "2026-01-03T00:00:00.000Z"); + const wokeAt = "2026-01-03T12:00:00.000Z"; + setShell(threadB, "2026-01-03T00:00:00.000Z", wokeAt); using _timers = fakeTimers(); vi.setSystemTime(NOW); // A palette pick already sent B's open with its query, so B's visit is a plain watermark advance. @@ -79,4 +81,7 @@ it("records the visit of the next thread at once after switching threads", async // Thread A's throttle must not hold back B's first watermark: leaving B // within the throttle window would otherwise keep it unread. expect(visitedThreadIds()).toEqual(["a", "b"]); + expect(mocks.visitRun.mock.calls.at(-1)?.[1]).toMatchObject({ + input: { threadId: threadB.threadId, visitedAt: wokeAt }, + }); }); diff --git a/apps/web/src/components/chat/ThreadVisitWatermark.tsx b/apps/web/src/components/chat/ThreadVisitWatermark.tsx index 7737f0dfb733b..aa92e790a1db1 100644 --- a/apps/web/src/components/chat/ThreadVisitWatermark.tsx +++ b/apps/web/src/components/chat/ThreadVisitWatermark.tsx @@ -1,13 +1,36 @@ import { useAtomMount } from "@effect/atom-react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; +import { threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { createThreadVisitTracker } from "@t3tools/client-runtime/state/thread-visits"; import { type ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; import { useUiStateStore } from "../../uiStateStore"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; +const wakeAwareVisitShell = Atom.family((key: string) => { + const ref = parseThreadKey(key); + return Atom.make((get) => { + const shell = get(environmentThreadShells.threadShellAtom(ref)); + if (shell === null) return null; + const nowMs = Date.now(); + const wokeAt = threadWokeAt(shell, { now: new Date(nowMs).toISOString() }); + const wakeAtMs = shell.snoozedUntil == null ? NaN : Date.parse(shell.snoozedUntil); + // Snooze expiry advances the visit watermark without a server event. + if (wokeAt === null && wakeAtMs > nowMs) { + const timer = setTimeout( + () => get.refreshSelf(), + Math.min(wakeAtMs - nowMs + 50, 2_147_483_647), + ); + get.addFinalizer(() => clearTimeout(timer)); + } + return { ...shell, wokeAt }; + }).pipe(Atom.withLabel(`web-thread-visit-shell:${key}`)); +}); + const threadVisitTracker = createThreadVisitTracker({ - threadShellAtom: environmentThreadShells.threadShellAtom, + threadShellAtom: (ref) => wakeAwareVisitShell(threadKey(ref)), visit: threadEnvironment.visit, markLocalVisit: (ref, visitedAt) => useUiStateStore.getState().markThreadVisited(scopedThreadKey(ref), visitedAt), From dd538b23c1b845775a05e2c930a9d9c57ab633fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 23:11:20 +0000 Subject: [PATCH 13/18] fix(mobile): finish Live Activity parity on current main - Settled threads leave the Live Activity, as they leave the inbox. - Carry snooze and visit fields through the awareness snapshot so the relay sees snooze wakes and read state. - Publish waiting subagents before parents on catch-up. - Use the stock relative date style; main no longer patches @expo/ui. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/widgets/AgentActivity.tsx | 2 +- .../src/relay/AgentAwarenessRelay.test.ts | 50 +++---------------- apps/server/src/relay/AgentAwarenessRelay.ts | 4 +- packages/shared/src/agentAwareness.ts | 19 +++---- 4 files changed, 16 insertions(+), 59 deletions(-) diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index be96439b30be2..2481691b1c0e5 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -309,7 +309,7 @@ function AgentActivity( isRead(row) ? ( { const parent = shell({ status: "completed" }); const child = waitingChild(); const { relay, secrets, publications } = yield* makeTestRelay({ - catchUpThreads: [parent, child], + catchUpThreads: [child, parent], readShell: (id) => Effect.succeed(id === child.id ? child : parent), }); yield* relay.start(); @@ -916,7 +916,7 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("does not alert an old completion after publishing its thread's tombstone", () => + it.effect("reopens an old completion silently after publishing its thread's tombstone", () => Effect.gen(function* () { const oldCompleted = shell({ status: "completed", @@ -943,47 +943,11 @@ describe("AgentAwarenessRelay", () => { yield* TestClock.adjust("5 seconds"); yield* relay.drain; assert.deepEqual( - publications.map((publication) => publication.state), - [null], - ); - }), - ); - - it.effect("alerts a completion settled during its hold after an earlier tombstone", () => - Effect.gen(function* () { - yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); - const { relay, currentShell, publications } = yield* makeTestRelay(); - const settledAt = DateTime.makeUnsafe(NOW); - yield* Ref.set(currentShell, { - ...shell({ status: "completed" }), - settledOverride: "settled", - settledAt, - lastVisitedAt: null, - }); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - - const completed = shell({ - status: "completed", - latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:06.000Z"), - }); - yield* Ref.set(currentShell, completed); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, { - ...completed, - settledOverride: "settled", - settledAt, - lastVisitedAt: null, - }); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; - assert.deepEqual( - publications.map((publication) => publication.state?.phase ?? null), - [null, "completed", null], + publications.map((publication) => [publication.state?.phase ?? null, publication.replay]), + [ + [null, false], + ["completed", true], + ], ); }), ); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index c8d6999108fc6..ff237dacbf1c4 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -618,7 +618,6 @@ export const make = Effect.gen(function* () { }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state, snapshot.subagentWait); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); - const previous = publishedStateByThread.get(threadId); const previousCompletion = publishedCompletions.get(threadId); const sameCompletion = snapshot.state?.completedAt !== undefined && @@ -878,7 +877,8 @@ export const make = Effect.gen(function* () { environmentId, now: nowMs, projects: projectSnapshot.projects, - threads: [...activitySnapshot.threads, ...liveChildren.filter((thread) => thread !== null)], + // Children first, so a parent's catch-up publish already sees its waiting child. + threads: [...liveChildren.filter((thread) => thread !== null), ...activitySnapshot.threads], }); if (activeThreadIds.length === 0) { yield* Effect.logDebug("agent activity snapshot has no publishable threads"); diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 41832b3b25b7d..1c7bd871896e4 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -105,7 +105,12 @@ export function projectThreadAwarenessV2( input: ProjectThreadAwarenessV2Input, ): AgentAwarenessState | null { const { environmentId, project, thread } = input; - if (!isListedThreadLineage(thread.lineage) || thread.archivedAt != null) return null; + if ( + !isListedThreadLineage(thread.lineage) || + thread.archivedAt != null || + thread.settledOverride === "settled" + ) + return null; const subagentRequest = input.pendingSubagentRequest ?? null; if ( input.now !== undefined && @@ -136,18 +141,6 @@ export function projectThreadAwarenessV2( return null; } if (phase === "completed" && thread.latestRunStopRequested === true) return null; - // Resolve the phase first so live or waiting work stays visible after settlement. - const runStatus = thread.activityRunStatus ?? thread.status; - if ( - thread.settledOverride === "settled" && - (phase === "completed" || - phase === "failed" || - phase === "woke" || - (phase === "running" && runStatus === "completed")) && - !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) - ) { - return null; - } const completedAt = phase === "completed" || phase === "failed" ? thread.latestRunCompletedAt : null; const unread = From 5a217cd1e4b86fe0e4150e66d367885a2b933363 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 23:26:15 +0000 Subject: [PATCH 14/18] fix(relay): keep Android Live Updates compatible and bound retained rows - Android `active` follows live work again, so an unread result no longer hides the inactive edge native dismissal uses to spot a new run. - Unread results expire like main's terminal rows instead of with 0, which released Android builds treat as already expired and which made every result of a thread share one terminal identity. Read results still end the card at once. - Retained rows age out after 24 hours and are pruned, so a missed settlement or archive tombstone cannot pin a row forever. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/agentActivity/AgentActivityRows.ts | 12 +++++-- .../src/agentActivity/FcmDeliveries.test.ts | 7 ++-- .../relay/src/agentActivity/FcmDeliveries.ts | 2 +- .../agentActivity/agentActivityAggregate.ts | 8 ++++- .../agentActivity/agentActivityPayloads.ts | 3 +- .../agentActivity/agentActivityPolicy.test.ts | 16 ++++++--- infra/relay/src/agentActivity/fcmPayloads.ts | 35 ++++++++----------- infra/relay/src/worker.ts | 1 + 8 files changed, 50 insertions(+), 34 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 18b7dd33a89d7..a5f8263ef0283 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -7,7 +7,7 @@ import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { and, desc, eq, isNull, lt, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, lt, or, sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -71,6 +71,7 @@ export class AgentActivityRows extends Context.Service< }) => Effect.Effect; readonly pruneTerminal: (input: { readonly updatedBefore: string; + readonly retainedBefore: string; }) => Effect.Effect; readonly remove: (input: { readonly environmentId: string; @@ -189,8 +190,13 @@ const make = Effect.gen(function* () { .where( and( sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, - sql`COALESCE(${relayAgentActivityRows.stateJson} ->> 'retained', 'false') != 'true'`, - lt(relayAgentActivityRows.updatedAt, input.updatedBefore), + or( + and( + sql`COALESCE(${relayAgentActivityRows.stateJson} ->> 'retained', 'false') != 'true'`, + lt(relayAgentActivityRows.updatedAt, input.updatedBefore), + ), + lt(relayAgentActivityRows.updatedAt, input.retainedBefore), + ), ), ) .pipe( diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 67541481631c4..2c334d497b359 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -343,16 +343,17 @@ describe("Android delivery routing", () => { ); it.effect.each(["completed", "woke"] as const)( - "retains a pending %s row without an automatic Android expiry", + "keeps a pending %s row on the card for released Android builds", (phase) => { const h = harness(); h.current.state = { ...state, phase, unread: phase === "completed", retained: true }; return Effect.gen(function* () { const delivery = yield* FcmDeliveries.FcmDeliveries; yield* delivery.process({ ...h.job, state: h.current.state }); + // An unread result must not hold `active`, or native dismissal never sees a new run. expect(h.sent[0]?.data).toMatchObject({ - active: "true", - activity_expires_at: "0", + active: String(phase === "woke"), + activity_expires_at: String(phase === "woke" ? 24 * 60 * 60_000 : 15 * 60_000), activity_working_count: "0", activity_attention_count: phase === "woke" ? "1" : "0", }); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 8c8b99090806a..8240490dffb63 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -331,7 +331,7 @@ const make = Effect.gen(function* () { } } const displayedAggregate = preferences.value.liveActivitiesEnabled ? aggregate : null; - const active = (displayedAggregate?.pendingCount ?? displayedAggregate?.activeCount ?? 0) > 0; + const active = (displayedAggregate?.activeCount ?? 0) > 0; if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; const data: Record = { t3_kind: "agent_activity", diff --git a/infra/relay/src/agentActivity/agentActivityAggregate.ts b/infra/relay/src/agentActivity/agentActivityAggregate.ts index 6493936010fde..4e98d44a1a6aa 100644 --- a/infra/relay/src/agentActivity/agentActivityAggregate.ts +++ b/infra/relay/src/agentActivity/agentActivityAggregate.ts @@ -8,6 +8,7 @@ import { isExpiredAgentActivityState, isTerminalPhase, MAX_ACTIVITY_ROWS, + RETAINED_AGENT_ACTIVITY_ROW_TTL_MS, sanitizeAgentActivityAggregateState, } from "./agentActivityPayloads.ts"; @@ -62,7 +63,12 @@ function isRecentTerminalState(state: RelayAgentActivityState, nowMs: number): b if (Number.isNaN(updatedAtMs)) { return false; } - return state.retained === true || nowMs - updatedAtMs <= TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS; + return ( + nowMs - updatedAtMs <= + (state.retained === true + ? RETAINED_AGENT_ACTIVITY_ROW_TTL_MS + : TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS) + ); } export function makeAggregateState(input: { diff --git a/infra/relay/src/agentActivity/agentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts index 885ba594c841a..dd71d920df622 100644 --- a/infra/relay/src/agentActivity/agentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -15,6 +15,8 @@ export function isTerminalPhase(state: RelayAgentActivityState): boolean { // Dead environments never publish terminal states; expire their rows so activeCount cannot remain inflated. const RUNNING_AGENT_ACTIVITY_ROW_TTL_MS = 2 * 60 * 60 * 1_000; const WAITING_AGENT_ACTIVITY_ROW_TTL_MS = 24 * 60 * 60 * 1_000; +// Unread results stay until read, but a missed tombstone must not pin them forever. +export const RETAINED_AGENT_ACTIVITY_ROW_TTL_MS = WAITING_AGENT_ACTIVITY_ROW_TTL_MS; export function agentActivityExpiresAt( state: Pick, @@ -37,7 +39,6 @@ export function isExpiredAgentActivityState( state: RelayAgentActivityState, nowMs: number, ): boolean { - if (state.phase === "woke" && state.retained === true) return false; const expiresAt = agentActivityExpiresAt(state); return !Number.isFinite(expiresAt) || nowMs > expiresAt; } diff --git a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts index 5f6147df5e91d..ee78b945c2387 100644 --- a/infra/relay/src/agentActivity/agentActivityPolicy.test.ts +++ b/infra/relay/src/agentActivity/agentActivityPolicy.test.ts @@ -104,12 +104,18 @@ describe("shared agent activity policy", () => { expect(shouldAlertForActivity({ ...state, phase: "woke", preferences, nowMs: 0 })).toBe(false); const woke = { ...state, phase: "woke" as const, retained: true }; expect( - makeAggregateState({ - activeStates: [woke], - terminalState: null, - nowMs: 2 * 24 * 60 * 60_000, - }), + makeAggregateState({ activeStates: [woke], terminalState: null, nowMs: 60 * 60_000 }), ).toMatchObject({ workingCount: 0, attentionCount: 1, pendingCount: 1 }); + // A missed tombstone cannot pin a retained row forever. + for (const retained of [woke, { ...state, phase: "completed" as const, retained: true }]) { + expect( + makeAggregateState({ + activeStates: [retained], + terminalState: null, + nowMs: 2 * 24 * 60 * 60_000, + }), + ).toBeNull(); + } const done = { ...state, phase: "completed" as const, unread: false }; expect(shouldAlertForActivity({ ...done, preferences, nowMs: 0 })).toBe(false); expect( diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index 7daa1af68e2e8..f7d60f81f68c0 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -24,7 +24,8 @@ export function androidActivityData( rows.filter((row) => activityPhasePriority(row.phase) === 0).length; const workingCount = aggregate?.workingCount ?? Math.max(0, activeCount - attentionCount); const unreadCount = aggregate?.unreadCount ?? 0; - const active = (aggregate?.pendingCount ?? activeCount) > 0; + // Native dismissal resets on an inactive-to-active edge, so unread results must not hold `active`. + const active = activeCount > 0; const failed = rows.some((row) => row.phase === "failed"); const clean = (value: string) => value.replace(/\s+/g, " ").trim(); const lines = rows.map((row) => @@ -47,26 +48,20 @@ export function androidActivityData( .join(" · ") : failed ? "Agent work failed" - : "All caught up"; - const pendingResult = lifecycleStates?.some( - (state) => - state.retained === true && - (state.phase === "woke" || - (state.unread !== false && (state.phase === "completed" || state.phase === "failed"))), + : unreadCount + ? `${unreadCount} done` + : "All caught up"; + // Released Android builds cancel the card on a past or zero expiry, and terminal identity includes it. + const expiresAt = Math.max( + 0, + ...(lifecycleStates ?? rows).map((row) => + row.phase === "completed" || row.phase === "failed" + ? row.unread === false + ? 0 + : Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS + : agentActivityExpiresAt(row), + ), ); - const expiresAt = - aggregate?.pendingCount === 0 || pendingResult - ? 0 - : Math.max( - 0, - ...(lifecycleStates ?? rows).map((row) => - row.phase === "completed" || row.phase === "failed" - ? "retained" in row && row.retained === true - ? 0 - : Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS - : agentActivityExpiresAt(row), - ), - ); return { active: String(active), activity_chip: active diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index e9da9a216726f..3f4c19009d811 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -487,6 +487,7 @@ export const layer = Api.make( Effect.flatMap(([activityRows, now]) => activityRows.pruneTerminal({ updatedBefore: DateTime.formatIso(DateTime.subtract(now, { minutes: 30 })), + retainedBefore: DateTime.formatIso(DateTime.subtract(now, { hours: 25 })), }), ), ), From ddcde3e81d97334d80ecbf0ec495b130657fb909 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 23:40:25 +0000 Subject: [PATCH 15/18] fix(relay): end iOS cards on read and narrow startup result replays - The freshly armed grace now covers only registration replays. A per-thread replay carries a real change such as reading the last result, so it ends the Live Activity instead of being dropped. - Startup and relink catch-up replay a finished thread only when its result is unread and inside the relay's 24 hour retention, instead of every unsettled completion. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 21 ++++++++++++++-- apps/server/src/relay/AgentAwarenessRelay.ts | 13 +++++++++- .../src/agentActivity/ApnsDeliveries.test.ts | 25 +++++++++++++++---- .../relay/src/agentActivity/ApnsDeliveries.ts | 6 ++++- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d45992a2a6775..0c59a3f180e1b 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -161,7 +161,7 @@ describe("startup agent activity", () => { }, ); - it("publishes active work and older unsettled completions after startup", () => { + it("publishes active work and only unread results inside the retention window", () => { const startedAt = DateTime.toEpochMillis(DateTime.makeUnsafe(NOW)); const oldCompleted = ThreadId.make("old-completed"); const newCompleted = ThreadId.make("new-completed"); @@ -193,11 +193,28 @@ describe("startup agent activity", () => { status: "failed", latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:01.000Z"), }), + shell({ + id: ThreadId.make("expired-failed"), + status: "failed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-03T11:59:00.000Z"), + }), + shell({ + id: ThreadId.make("unread-completed"), + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T11:59:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe("2026-09-04T11:00:00.000Z"), + }), shell({ id: ThreadId.make("idle"), status: "idle" }), shell({ id: ThreadId.make("missing-project"), projectId: ProjectId.make("missing") }), ], }); - assert.deepStrictEqual(ids, [THREAD_ID, oldCompleted, newCompleted, oldFailed, newFailed]); + // Completions never visited count as read; failures stay unread until visited. + assert.deepStrictEqual(ids, [ + THREAD_ID, + oldFailed, + newFailed, + ThreadId.make("unread-completed"), + ]); }); }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index ff237dacbf1c4..02913308376e9 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -374,6 +374,9 @@ function terminalWorkSinceStart(thread: AgentAwarenessThread, startedAt: number) ); } +// Matches the relay's retention of unread results; older rows would be dropped on arrival. +const RETAINED_RESULT_REPLAY_WINDOW_MS = 24 * 60 * 60 * 1_000; + export function resolveAgentAwarenessRelayActiveThreadIds(input: { readonly environmentId: EnvironmentId; readonly now: number; @@ -395,7 +398,15 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { thread, now: input.now, }); - return state !== null; + if (state === null) return false; + if (state.phase !== "completed" && state.phase !== "failed") return true; + // Catch-up runs on every restart and relink, so replay only results the phone still shows. + return ( + state.unread === true && + thread.latestRunCompletedAt != null && + input.now - DateTime.toEpochMillis(thread.latestRunCompletedAt) <= + RETAINED_RESULT_REPLAY_WINDOW_MS + ); }) .map((thread) => thread.id); } diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 4b33c04c53a1b..1ada40f61464e 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -1134,9 +1134,14 @@ describe("fast completion delivery", () => { }, ); - it.effect.each([true, false])( - "preserves a freshly armed token only for a replayed read snapshot: replay=%s", - (replay) => { + it.effect.each([ + { replay: true, replayThread: false, ends: false }, + { replay: false, replayThread: false, ends: true }, + // A thread replay is a real read of an already announced result. + { replay: true, replayThread: true, ends: true }, + ])( + "preserves a freshly armed token only for a registration replay: %o", + ({ replay, replayThread, ends }) => { const queuedJobs: SignedApnsDeliveryJob[] = []; const read = { ...aggregate, @@ -1147,8 +1152,18 @@ describe("fast completion delivery", () => { }; return Effect.gen(function* () { const deliveries = yield* ApnsDeliveries.ApnsDeliveries; - yield* deliveries.sendForTarget({ target, aggregate: read, nowMs: 0, replay }); - expect(queuedJobs).toHaveLength(replay ? 0 : 1); + yield* deliveries.sendForTarget({ + target, + aggregate: read, + nowMs: 0, + replay, + ...(replayThread + ? { replayThread: { environmentId: state.environmentId, threadId: state.threadId } } + : {}), + }); + expect(queuedJobs.map((job) => job.payload.kind)).toEqual( + ends ? ["live_activity_end"] : [], + ); }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs }))); }, ); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index d5e0f0e55e93f..a251c7c27609c 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -222,6 +222,7 @@ function chooseLiveActivityDelivery(input: { readonly aggregate: RelayAgentActivityAggregateState | null; readonly nowMs: number; readonly replay?: boolean; + readonly threadReplay?: boolean; }): ChosenLiveActivityDelivery | "suppressed" | null { const preferences = parsePreferences(input.target.preferences_json); if (preferences?.liveActivitiesEnabled === false) { @@ -245,8 +246,9 @@ function chooseLiveActivityDelivery(input: { : DateTime.make(input.target.remote_started_at), { onNone: () => null, onSome: (dt) => dt.epochMilliseconds }, ); + // Per-thread replays carry real changes such as a read result, so only registration replays wait. if ( - (input.aggregate === null || input.replay) && + (input.aggregate === null || (input.replay && !input.threadReplay)) && armedAtMs !== null && input.nowMs - armedAtMs < FRESHLY_ARMED_GRACE_MS ) { @@ -294,6 +296,7 @@ function chooseDelivery(input: { readonly aggregate: RelayAgentActivityAggregateState | null; readonly nowMs: number; readonly replay?: boolean; + readonly threadReplay?: boolean; }): ChosenDelivery | null { const liveActivityDelivery = chooseLiveActivityDelivery(input); if (liveActivityDelivery === "suppressed") { @@ -1074,6 +1077,7 @@ const make = Effect.gen(function* () { aggregate: input.aggregate, nowMs: input.nowMs, replay: input.replay ?? false, + threadReplay: input.replayThread !== undefined, }); if (!delivery) { return null; From 9c3f19d2a97777ddfd31a91c2b98eab25f48600b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 00:00:35 +0000 Subject: [PATCH 16/18] fix(server): catch up results finished since startup after publishing resumes Excluding read completions from catch-up left a running row the relay already held unchanged when publishing was re-enabled or the server restarted. Restore main's results-since-startup branch alongside the unread retention window. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/relay/AgentAwarenessRelay.test.ts | 8 +++++--- apps/server/src/relay/AgentAwarenessRelay.ts | 6 +++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 0c59a3f180e1b..d66e925b9e549 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -161,7 +161,7 @@ describe("startup agent activity", () => { }, ); - it("publishes active work and only unread results inside the retention window", () => { + it("publishes active work, results since startup, and older unread results inside the retention window", () => { const startedAt = DateTime.toEpochMillis(DateTime.makeUnsafe(NOW)); const oldCompleted = ThreadId.make("old-completed"); const newCompleted = ThreadId.make("new-completed"); @@ -169,7 +169,8 @@ describe("startup agent activity", () => { const newFailed = ThreadId.make("new-failed"); const ids = AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ environmentId: EnvironmentId.make("relay-env"), - now: startedAt, + startedAt, + now: startedAt + 60_000, projects: [{ id: PROJECT_ID, title: "Project" }], threads: [ shell(), @@ -208,9 +209,10 @@ describe("startup agent activity", () => { shell({ id: ThreadId.make("missing-project"), projectId: ProjectId.make("missing") }), ], }); - // Completions never visited count as read; failures stay unread until visited. + // Completions never visited count as read; one since startup still replaces a stale row. assert.deepStrictEqual(ids, [ THREAD_ID, + newCompleted, oldFailed, newFailed, ThreadId.make("unread-completed"), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 02913308376e9..a5fb5ff1a83e5 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -379,6 +379,7 @@ const RETAINED_RESULT_REPLAY_WINDOW_MS = 24 * 60 * 60 * 1_000; export function resolveAgentAwarenessRelayActiveThreadIds(input: { readonly environmentId: EnvironmentId; + readonly startedAt: number; readonly now: number; readonly projects: ReadonlyArray>; readonly threads: ReadonlyArray; @@ -400,7 +401,9 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { }); if (state === null) return false; if (state.phase !== "completed" && state.phase !== "failed") return true; - // Catch-up runs on every restart and relink, so replay only results the phone still shows. + // A result since startup may replace a row published before a restart or while publishing was off. + if (terminalWorkSinceStart(thread, input.startedAt)) return true; + // Catch-up runs on every restart and relink, so replay older results only while the phone shows them. return ( state.unread === true && thread.latestRunCompletedAt != null && @@ -886,6 +889,7 @@ export const make = Effect.gen(function* () { ); const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, + startedAt, now: nowMs, projects: projectSnapshot.projects, // Children first, so a parent's catch-up publish already sees its waiting child. From f6b3dba964fbd229149c69a36bdaac481d0bc4eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 00:12:27 +0000 Subject: [PATCH 17/18] fix(relay): match inbox snoozes, keep Android results, alert settled completions - A question asked before a snooze stays deferred like the inbox; new questions and approvals still wake the thread early. - Android result cards expire with the relay's 24-hour retention of unread results instead of timing out after 15 minutes. - Restore the held-completion path so a thread that settles during the completion hold still alerts Done before its row clears. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 49 +++++++++++++++++-- apps/server/src/relay/AgentAwarenessRelay.ts | 47 +++++++++++++++++- .../src/agentActivity/FcmDeliveries.test.ts | 2 +- infra/relay/src/agentActivity/fcmPayloads.ts | 11 ++++- packages/shared/src/agentAwareness.test.ts | 27 ++++++++++ packages/shared/src/agentAwareness.ts | 10 +++- 6 files changed, 135 insertions(+), 11 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d66e925b9e549..a08228a440feb 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -870,7 +870,7 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("removes a thread settled during a completion hold without publishing Done", () => + it.effect("still publishes a completion when the thread settles during the hold", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); @@ -888,14 +888,14 @@ describe("AgentAwarenessRelay", () => { yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase ?? null), - ["running", null], + ["running", "completed"], ); yield* TestClock.adjust("5 seconds"); yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase ?? null), - ["running", null], + ["running", "completed", null], ); }), ); @@ -988,7 +988,46 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("retries with removal instead of a stale completion after settlement", () => + it.effect("alerts a completion settled during its hold after an earlier tombstone", () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); + const { relay, currentShell, publications } = yield* makeTestRelay(); + const settledAt = DateTime.makeUnsafe(NOW); + yield* Ref.set(currentShell, { + ...shell({ status: "completed" }), + settledOverride: "settled", + settledAt, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + + const completed = shell({ + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:06.000Z"), + }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + [null, "completed", null], + ); + }), + ); + + it.effect("retries a failed completion even after the thread settles", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay({ respond: (attempt) => @@ -1012,7 +1051,7 @@ describe("AgentAwarenessRelay", () => { yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase ?? null), - ["running", "completed", null], + ["running", "completed", "completed"], ); yield* TestClock.adjust("5 seconds"); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index a5fb5ff1a83e5..63f290a358083 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -474,6 +474,8 @@ export const make = Effect.gen(function* () { // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); + // Threads whose confirmation holds a completion, so a thread that settles during the hold still alerts. + const heldCompletions = new Set(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -493,6 +495,7 @@ export const make = Effect.gen(function* () { publishedRelayConfig = undefined; cachedRelayClient = undefined; publishConfirmDeadlines.clear(); + heldCompletions.clear(); yield* Ref.set(publishedStateByThreadRef, new Map()); }); @@ -643,9 +646,14 @@ export const make = Effect.gen(function* () { (!hasLivePublishedState(publishedStateByThread, threadId) && Option.isSome(thread) && !terminalWorkSinceStart(thread.value, startedAt))); - if (publishedStateByThread.get(threadId) === publishIdentity) { + if ( + publishedStateByThread.get(threadId) === publishIdentity && + // A held completion settled after an earlier clear still has to alert. + !(snapshot.state === null && heldCompletions.has(threadId)) + ) { // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, @@ -660,6 +668,7 @@ export const make = Effect.gen(function* () { const requiresConfirmation = snapshot.state === null || (snapshot.state?.phase === "completed" && !replay); if (requiresConfirmation) { + if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -684,8 +693,43 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); + // Settled during the hold, not resumed: alert the current completion, then clear the card + // on a new window. Only settlement may hide it; queued or silent runs also project to null. + const unsettled = + snapshot.state === null && + heldCompletions.has(threadId) && + Option.isSome(thread) && + thread.value.settledOverride === "settled" + ? resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + thread: Option.some({ ...thread.value, settledOverride: null }), + project, + now: nowMs, + pendingSubagentRequest: null, + }) + : null; + if (unsettled?.state?.phase === "completed") { + const completion = unsettled.state; + yield* publishState({ + projectId: unsettled.projectId, + state: completion, + reason: unsettled.reason, + }).pipe(withRelayClientTracing); + publishedCompletions.set(threadId, { + phase: completion.phase, + completedAt: completion.completedAt, + }); + heldCompletions.delete(threadId); + publishConfirmDeadlines.set(threadId, nowMs + 5_000); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => + new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(completion)), + ); + yield* schedulePublishConfirm(threadId); + return; + } } else { publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); } if (snapshot.reason === "thread-not-found") { @@ -725,6 +769,7 @@ export const make = Effect.gen(function* () { childAlertByThread.set(threadId, { requestId: childRequestId, updatedAt: state.updatedAt }); } publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => new Map(publishedStates).set(threadId, publishIdentity), ); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 2c334d497b359..f56761a812864 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -353,7 +353,7 @@ describe("Android delivery routing", () => { // An unread result must not hold `active`, or native dismissal never sees a new run. expect(h.sent[0]?.data).toMatchObject({ active: String(phase === "woke"), - activity_expires_at: String(phase === "woke" ? 24 * 60 * 60_000 : 15 * 60_000), + activity_expires_at: String(24 * 60 * 60_000), activity_working_count: "0", activity_attention_count: phase === "woke" ? "1" : "0", }); diff --git a/infra/relay/src/agentActivity/fcmPayloads.ts b/infra/relay/src/agentActivity/fcmPayloads.ts index f7d60f81f68c0..b78cd8944111c 100644 --- a/infra/relay/src/agentActivity/fcmPayloads.ts +++ b/infra/relay/src/agentActivity/fcmPayloads.ts @@ -6,7 +6,10 @@ import { activityPhasePriority, TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, } from "./agentActivityAggregate.ts"; -import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; +import { + agentActivityExpiresAt, + RETAINED_AGENT_ACTIVITY_ROW_TTL_MS, +} from "./agentActivityPayloads.ts"; export function androidActivityData( aggregate: RelayAgentActivityAggregateState | null, @@ -58,7 +61,11 @@ export function androidActivityData( row.phase === "completed" || row.phase === "failed" ? row.unread === false ? 0 - : Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS + : Date.parse(row.updatedAt) + + // Match the relay's retention so a kept unread result does not time out first. + ("retained" in row && row.retained === true + ? RETAINED_AGENT_ACTIVITY_ROW_TTL_MS + : TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS) : agentActivityExpiresAt(row), ), ); diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 86b1f3a5ea0fd..8ea783d94fb64 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -119,6 +119,33 @@ describe("projectThreadAwarenessV2", () => { ).toMatchObject({ phase: "completed", unread, completedAt: NOW, retained: true }); }); + it.each([ + { kind: "user_input", createdAt: "2026-05-22T11:59:00.000Z", phase: null }, + { kind: "user_input", createdAt: "2026-05-22T12:01:00.000Z", phase: "waiting_for_input" }, + { kind: "command", createdAt: "2026-05-22T11:59:00.000Z", phase: "waiting_for_approval" }, + ] as const)( + "matches the inbox for a $kind request created $createdAt during a snooze", + ({ kind, createdAt, phase }) => { + const state = projectThreadAwarenessV2({ + environmentId: "env-1" as EnvironmentId, + project, + now: Date.parse("2026-05-22T12:02:00.000Z"), + thread: { + ...v2Thread({ + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-snoozed"), + kind, + createdAt: DateTime.makeUnsafe(createdAt), + }, + }), + snoozedAt: updatedAt, + snoozedUntil: DateTime.makeUnsafe("2026-05-22T14:00:00.000Z"), + }, + }); + expect(state?.phase ?? null).toBe(phase); + }, + ); + it("does not announce completion after a manual stop", () => { const input = { environmentId: "env-1" as EnvironmentId, diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 1c7bd871896e4..66264e41bf766 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -123,8 +123,14 @@ export function projectThreadAwarenessV2( (thread.latestRunCompletedAt != null && DateTime.toEpochMillis(thread.latestRunCompletedAt) > DateTime.toEpochMillis(thread.snoozedAt))); - if (thread.pendingRuntimeRequest == null && subagentRequest === null && !failedAfterSnooze) - return null; + // Like the inbox, snoozing an existing question defers it; new questions and approvals wake. + const request = thread.pendingRuntimeRequest; + const requestWakes = + request != null && + (request.kind !== "user_input" || + thread.snoozedAt == null || + DateTime.toEpochMillis(request.createdAt) > DateTime.toEpochMillis(thread.snoozedAt)); + if (!requestWakes && subagentRequest === null && !failedAfterSnooze) return null; } const snoozeWakeAt = thread.snoozedUntil; const woke = From cc1757b99eab26c05d06431a0fb2f08109d7f23c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 00:25:33 +0000 Subject: [PATCH 18/18] fix(relay): keep settled live work, group off-card results, catch up read rows - Settlement hides only finished work again, so a settled thread that is still running or waiting on an approval or question keeps its row and push. - An off-card completion joins the grouped Android alert instead of losing to another thread's on-card alert. - Catch-up re-sends every result in the relay's 24h retention window, so results read, settled or hidden before a restart update or clear the row. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 20 +++++++---- apps/server/src/relay/AgentAwarenessRelay.ts | 16 ++++----- .../src/agentActivity/FcmDeliveries.test.ts | 27 ++++++++++++++ .../relay/src/agentActivity/FcmDeliveries.ts | 35 +++++++++++-------- packages/shared/src/agentAwareness.test.ts | 31 +++++++++------- packages/shared/src/agentAwareness.ts | 19 ++++++---- 6 files changed, 99 insertions(+), 49 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index a08228a440feb..7b4003572b86a 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -161,7 +161,7 @@ describe("startup agent activity", () => { }, ); - it("publishes active work, results since startup, and older unread results inside the retention window", () => { + it("publishes active work and every result inside the retention window", () => { const startedAt = DateTime.toEpochMillis(DateTime.makeUnsafe(NOW)); const oldCompleted = ThreadId.make("old-completed"); const newCompleted = ThreadId.make("new-completed"); @@ -205,17 +205,25 @@ describe("startup agent activity", () => { latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T11:59:00.000Z"), lastVisitedAt: DateTime.makeUnsafe("2026-09-04T11:00:00.000Z"), }), + shell({ + id: ThreadId.make("settled-completed"), + status: "completed", + settledOverride: "settled", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T11:59:00.000Z"), + }), shell({ id: ThreadId.make("idle"), status: "idle" }), shell({ id: ThreadId.make("missing-project"), projectId: ProjectId.make("missing") }), ], }); - // Completions never visited count as read; one since startup still replaces a stale row. + // Read and settled results still replace a row the relay may hold from before. assert.deepStrictEqual(ids, [ THREAD_ID, + oldCompleted, newCompleted, oldFailed, newFailed, ThreadId.make("unread-completed"), + ThreadId.make("settled-completed"), ]); }); }); @@ -1060,7 +1068,7 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("removes settled live work while its monitor is still running", () => + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); @@ -1073,15 +1081,15 @@ describe("AgentAwarenessRelay", () => { yield* relay.publishThread(THREAD_ID); yield* TestClock.adjust("5 seconds"); yield* relay.drain; - assert.equal(publications.length, 2); - assert.equal(publications[1]?.state, null); + assert.equal(publications.length, 1); assert.equal(publications[0]?.state?.phase, "running"); yield* Ref.set(currentShell, { ...held, pendingBackgroundTasks: [] }); yield* relay.publishThread(THREAD_ID); yield* TestClock.adjust("5 seconds"); yield* relay.drain; - assert.isTrue(publications.slice(1).every((publication) => publication.state === null)); + assert.equal(publications.length, 2); + assert.equal(publications[1]?.state, null); }), ); it.effect("publishes a held completion when its background item ends, not on tool output", () => diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 63f290a358083..f724216968121 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -374,7 +374,7 @@ function terminalWorkSinceStart(thread: AgentAwarenessThread, startedAt: number) ); } -// Matches the relay's retention of unread results; older rows would be dropped on arrival. +// Matches the relay's retention of results; older rows have already expired there. const RETAINED_RESULT_REPLAY_WINDOW_MS = 24 * 60 * 60 * 1_000; export function resolveAgentAwarenessRelayActiveThreadIds(input: { @@ -399,16 +399,14 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { thread, now: input.now, }); - if (state === null) return false; - if (state.phase !== "completed" && state.phase !== "failed") return true; - // A result since startup may replace a row published before a restart or while publishing was off. - if (terminalWorkSinceStart(thread, input.startedAt)) return true; - // Catch-up runs on every restart and relink, so replay older results only while the phone shows them. + if (state !== null && state.phase !== "completed" && state.phase !== "failed") return true; + // The relay may still hold a result from before a restart or while publishing was off: re-send + // it so read results update and settled or hidden ones (null, a tombstone) clear the row. return ( - state.unread === true && thread.latestRunCompletedAt != null && - input.now - DateTime.toEpochMillis(thread.latestRunCompletedAt) <= - RETAINED_RESULT_REPLAY_WINDOW_MS + (terminalWorkSinceStart(thread, input.startedAt) || + input.now - DateTime.toEpochMillis(thread.latestRunCompletedAt) <= + RETAINED_RESULT_REPLAY_WINDOW_MS) ); }) .map((thread) => thread.id); diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index f56761a812864..a701513b3c77f 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -403,6 +403,33 @@ describe("Android delivery routing", () => { }).pipe(Effect.provide(h.layer)); }); + it.effect("groups an off-card completion with a new on-card failure", () => { + const h = harness(); + const oldFailures = Array.from({ length: 4 }, (_, i) => ({ + ...state, + threadId: ThreadId.make(`failure-${i}`), + threadTitle: `Old failure ${i}`, + phase: "failed" as const, + unread: true, + retained: true, + })); + const newFailure = { + ...oldFailures[0]!, + threadId: ThreadId.make("new-failure"), + threadTitle: "New failure", + }; + h.current.target.last_aggregate_json = encodeJson(aggregateFor([state, ...oldFailures])); + h.current.state = { ...state, phase: "completed", unread: true, retained: true }; + h.current.otherStates = [...oldFailures, newFailure]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process({ ...h.job, state: h.current.state }); + expect(h.sent[0]?.data.alert_title).toBe("2 agents finished"); + expect(h.sent[0]?.data.alert_body).toContain(state.threadTitle); + expect(h.sent[0]?.data.alert_body).toContain("New failure"); + }).pipe(Effect.provide(h.layer)); + }); + it("expires an Android card immediately after its last result is read", () => { const read = { ...state, phase: "completed" as const, unread: false, retained: true }; expect(androidActivityData(aggregateFor([read]), 60_000)).toMatchObject({ diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 8240490dffb63..3ea70e2445244 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -274,21 +274,26 @@ const make = Effect.gen(function* () { !isExpiredAgentActivityState(state, now.epochMilliseconds) ? state : null; - alert = - androidAlertForAggregate({ - previousAggregate, - nextAggregate: { - ...aggregate, - activities: aggregate.activities.filter((row) => - allowedEnvironments.has(row.environmentId), - ), - }, - preferences: preferences.value, - nowMs: now.epochMilliseconds, - }) ?? - (hiddenCurrentState - ? androidAlertForState(hiddenCurrentState, preferences.value, now.epochMilliseconds) - : null); + // An off-card result joins the group instead of losing to another thread's alert. + const hiddenRow = hiddenCurrentState + ? makeAggregateState({ + activeStates: [hiddenCurrentState], + terminalState: null, + nowMs: now.epochMilliseconds, + })?.activities[0] + : undefined; + alert = androidAlertForAggregate({ + previousAggregate, + nextAggregate: { + ...aggregate, + activities: [ + ...aggregate.activities.filter((row) => allowedEnvironments.has(row.environmentId)), + ...(hiddenRow ? [hiddenRow] : []), + ], + }, + preferences: preferences.value, + nowMs: now.epochMilliseconds, + }); } else if ( deliveryUser?.notificationsEnabled && state?.phase === job.state.phase && diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 8ea783d94fb64..09bd8062e55fd 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -252,18 +252,21 @@ describe("projectThreadAwarenessV2", () => { ).toBeNull(); }); - it.each(["starting", "running", "waiting"] as const)("excludes settled %s work", (status) => { - expect( - projectThreadAwarenessV2({ - environmentId: "env-1" as EnvironmentId, - project, - thread: v2Thread({ status, settledOverride: "settled" }), - }), - ).toBeNull(); - }); + it.each(["starting", "running", "waiting"] as const)( + "keeps settled %s work visible", + (status) => { + expect( + projectThreadAwarenessV2({ + environmentId: "env-1" as EnvironmentId, + project, + thread: v2Thread({ status, settledOverride: "settled" }), + }), + ).toMatchObject({ phase: status === "starting" ? "starting" : "running" }); + }, + ); it.each(["user_input", "command"] as const)( - "excludes a settled thread with a pending %s request", + "keeps a settled terminal thread with a pending %s request", (kind) => { expect( projectThreadAwarenessV2({ @@ -279,11 +282,13 @@ describe("projectThreadAwarenessV2", () => { }, }), }), - ).toBeNull(); + ).toMatchObject({ + phase: kind === "user_input" ? "waiting_for_input" : "waiting_for_approval", + }); }, ); - it("excludes a settled thread with an awaited monitor", () => { + it("keeps settled work held open by an awaited monitor", () => { expect( projectThreadAwarenessV2({ environmentId: "env-1" as EnvironmentId, @@ -294,7 +299,7 @@ describe("projectThreadAwarenessV2", () => { pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor", awaited: true }], }), }), - ).toBeNull(); + ).toMatchObject({ phase: "running" }); }); it.each([ diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 66264e41bf766..87f996928d591 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -105,12 +105,7 @@ export function projectThreadAwarenessV2( input: ProjectThreadAwarenessV2Input, ): AgentAwarenessState | null { const { environmentId, project, thread } = input; - if ( - !isListedThreadLineage(thread.lineage) || - thread.archivedAt != null || - thread.settledOverride === "settled" - ) - return null; + if (!isListedThreadLineage(thread.lineage) || thread.archivedAt != null) return null; const subagentRequest = input.pendingSubagentRequest ?? null; if ( input.now !== undefined && @@ -147,6 +142,18 @@ export function projectThreadAwarenessV2( return null; } if (phase === "completed" && thread.latestRunStopRequested === true) return null; + // Resolve the phase first so live or waiting work stays visible after settlement. + const runStatus = thread.activityRunStatus ?? thread.status; + if ( + thread.settledOverride === "settled" && + (phase === "completed" || + phase === "failed" || + phase === "woke" || + (phase === "running" && runStatus === "completed")) && + !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) + ) { + return null; + } const completedAt = phase === "completed" || phase === "failed" ? thread.latestRunCompletedAt : null; const unread =