Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2e3a6eb
fix(mobile): keep Live Activity status aligned with the inbox
jakeleventhal Oct 28, 2026
25ad42e
fix(mobile): handle replay races and terminal activity headers
jakeleventhal Oct 28, 2026
56552f5
fix(mobile): preserve pending activity and off-card alerts
jakeleventhal Oct 28, 2026
89473ad
fix(mobile): preserve approval alerts during off-card updates
jakeleventhal Oct 28, 2026
4be6296
fix(mobile): acknowledge replayed threads without consuming other alerts
jakeleventhal Oct 28, 2026
cb5649b
test(mobile): suppress alerts in retained activity lifecycle case
jakeleventhal Oct 28, 2026
b408f9d
fix(mobile): retain unacknowledged snooze wakes in agent activity
jakeleventhal Oct 28, 2026
8df653b
fix(mobile): let read timestamps shrink on narrow activity cards
jakeleventhal Oct 28, 2026
de60a08
fix(mobile): link activity to the first visible attention row
jakeleventhal Oct 28, 2026
be2bcc0
test(mobile): cover minimal activity glyph for snooze wakes
jakeleventhal Oct 28, 2026
a2c48bd
merge: bring main into live activity parity
maria-rcks Oct 31, 2026
ae56092
fix(relay): preserve live activity transitions during replay
maria-rcks Oct 31, 2026
7619036
fix(web): acknowledge snooze wakes when viewing threads
maria-rcks Nov 1, 2026
1dce965
merge: bring main into live activity parity
juliusmarminge Nov 11, 2026
dd538b2
fix(mobile): finish Live Activity parity on current main
juliusmarminge Nov 11, 2026
5a217cd
fix(relay): keep Android Live Updates compatible and bound retained rows
juliusmarminge Nov 11, 2026
ddcde3e
fix(relay): end iOS cards on read and narrow startup result replays
juliusmarminge Nov 11, 2026
33bfa96
Merge remote-tracking branch 'origin/main' into fix/live-activity-des…
juliusmarminge Nov 11, 2026
9c3f19d
fix(server): catch up results finished since startup after publishing…
juliusmarminge Nov 12, 2026
f6b3dba
fix(relay): match inbox snoozes, keep Android results, alert settled …
juliusmarminge Nov 12, 2026
cc1757b
fix(relay): keep settled live work, group off-card results, catch up …
juliusmarminge Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Expand All @@ -80,6 +81,7 @@ internal fun activityPhase(data: Map<String, String>, rows: List<ActivityRow>):
"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
Expand All @@ -94,9 +96,11 @@ internal class ActivityPresentation(data: Map<String, String>, 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")
Expand All @@ -109,7 +113,7 @@ internal class ActivityPresentation(data: Map<String, String>, 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"
}
Expand All @@ -136,7 +140,7 @@ internal class ActivityPresentation(data: Map<String, String>, private val activ
builder.setSubText(
listOfNotNull(
hero!!.project.takeIf { singleProject && it.isNotBlank() },
if (activeCount > 0) "$activeCount active" else "$threadCount threads"
if (workingCount > 0) "$workingCount working" else "$threadCount threads"
).joinToString(" · ")
)
}
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

A later retained completion on the same thread is canceled even when no alert covered it, because singleTerminal() produces the same path\ncompleted\n0 key and coveredTerminal is never invalidated. Include a per-completion identifier in the coverage key or clear coverage when a new run starts.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt around line 276:

A later retained completion on the same thread is canceled even when no alert covered it, because `singleTerminal()` produces the same `path\ncompleted\n0` key and `coveredTerminal` is never invalidated. Include a per-completion identifier in the coverage key or clear coverage when a new run starts.

Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,16 @@ 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
Expand Down Expand Up @@ -314,6 +320,9 @@ object AgentNotifications {
if (ongoing) builder.addAction(0, "Dismiss", dismissIntent)
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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() {
lifecycle.currentState = Lifecycle.State.RESUMED
AgentNotifications.receive(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
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()
Expand Down Expand Up @@ -354,8 +354,12 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
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,
Expand Down Expand Up @@ -396,6 +400,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
for (phase in listOf(
"waiting_for_approval",
"waiting_for_input",
"woke",
"stale",
"failed",
"completed"
Expand Down Expand Up @@ -463,7 +468,7 @@ class AgentNotificationsTest : AgentNotificationsFixture() {
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,67 @@ describe("makeRelayDeviceRegistrationRequest", () => {
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 };

Expand Down
17 changes: 5 additions & 12 deletions apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,20 +1075,13 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn(
}).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<LiveActivity<AgentActivityProps>>));
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",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/showcase/showcaseAgentActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const STATUS_BY_PHASE: Record<AgentActivityPhase, string> = {
waiting_for_approval: "Approval",
waiting_for_input: "Input",
stale: "Waiting",
woke: "Woke",
completed: "Done",
failed: "Failed",
};
Expand Down
Loading
Loading