diff --git a/docs/superpowers/plans/2026-04-21-master-agent-background-notification.md b/docs/superpowers/plans/2026-04-21-master-agent-background-notification.md new file mode 100644 index 0000000..7122c30 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-master-agent-background-notification.md @@ -0,0 +1,497 @@ +# Master Agent Background Notification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 Boss Android 原生客户端补齐主 Agent 会话后台通知,让用户离开前台后仍能及时收到主 Agent 新回复,并避免前台重复提醒。 + +**Architecture:** 继续复用现有 SSE 实时链路,在 Android 本地增加应用前后台可见性跟踪和主 Agent 通知路由器。`BossRealtimeClient` 负责把包含完整 payload 的实时事件抛给界面层,`MainActivity` 与 `ProjectDetailActivity` 在收到 `master-agent` 新消息事件时交给通知路由器判定是否通知、如何去重以及点击回跳。通知逻辑完全本地化,不引入 FCM,也不改变服务端协议。 + +**Tech Stack:** Android Java, NotificationCompat, Robolectric, JUnit4, existing Boss SSE client + +--- + +### Task 1: 建立应用前后台与当前会话可见性跟踪 + +**Files:** +- Create: `android/app/src/main/java/com/hyzq/boss/BossAppVisibilityTracker.java` +- Modify: `android/app/src/main/java/com/hyzq/boss/BossApplication.java` +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java` +- Test: `android/app/src/test/java/com/hyzq/boss/BossNotificationRouterTest.java` + +- [ ] **Step 1: 写失败测试,约束前后台和当前会话可见性状态** + +```java +@Test +public void visibilityTrackerMarksForegroundAndVisibleProject() { + BossAppVisibilityTracker tracker = new BossAppVisibilityTracker(); + + tracker.onAppForegrounded(); + tracker.setVisibleProjectId("master-agent"); + + assertTrue(tracker.isAppInForeground()); + assertEquals("master-agent", tracker.getVisibleProjectId()); + + tracker.clearVisibleProjectId("master-agent"); + tracker.onAppBackgrounded(); + + assertFalse(tracker.isAppInForeground()); + assertNull(tracker.getVisibleProjectId()); +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.BossNotificationRouterTest.visibilityTrackerMarksForegroundAndVisibleProject` +Expected: FAIL,提示 `BossAppVisibilityTracker` 或测试方法不存在。 + +- [ ] **Step 3: 写最小实现** + +```java +package com.hyzq.boss; + +import androidx.annotation.Nullable; + +final class BossAppVisibilityTracker { + private volatile boolean appInForeground; + private volatile @Nullable String visibleProjectId; + + void onAppForegrounded() { + appInForeground = true; + } + + void onAppBackgrounded() { + appInForeground = false; + } + + boolean isAppInForeground() { + return appInForeground; + } + + void setVisibleProjectId(@Nullable String projectId) { + visibleProjectId = projectId == null || projectId.trim().isEmpty() ? null : projectId.trim(); + } + + void clearVisibleProjectId(@Nullable String projectId) { + if (visibleProjectId == null) { + return; + } + if (projectId == null || visibleProjectId.equals(projectId.trim())) { + visibleProjectId = null; + } + } + + @Nullable + String getVisibleProjectId() { + return visibleProjectId; + } +} +``` + +在 `BossApplication` 中挂单例: + +```java +public final class BossApplication extends Application { + private final BossAppVisibilityTracker visibilityTracker = new BossAppVisibilityTracker(); + + @Override + public void onCreate() { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + super.onCreate(); + } + + public BossAppVisibilityTracker getVisibilityTracker() { + return visibilityTracker; + } +} +``` + +在 `ProjectDetailActivity` 的 `onResume/onPause` 中同步当前会话: + +```java +@Override +protected void onResume() { + super.onResume(); + ((BossApplication) getApplication()).getVisibilityTracker().onAppForegrounded(); + ((BossApplication) getApplication()).getVisibilityTracker().setVisibleProjectId(projectId); + ... +} + +@Override +protected void onPause() { + ((BossApplication) getApplication()).getVisibilityTracker().clearVisibleProjectId(projectId); + ... + super.onPause(); +} +``` +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.BossNotificationRouterTest.visibilityTrackerMarksForegroundAndVisibleProject` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add android/app/src/main/java/com/hyzq/boss/BossAppVisibilityTracker.java android/app/src/main/java/com/hyzq/boss/BossApplication.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/test/java/com/hyzq/boss/BossNotificationRouterTest.java +git commit -m "feat: track app visibility for master agent notifications" +``` + +### Task 2: 实现主 Agent 后台通知路由与去重 + +**Files:** +- Create: `android/app/src/main/java/com/hyzq/boss/BossNotificationRouter.java` +- Modify: `android/app/src/main/AndroidManifest.xml` +- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java` +- Test: `android/app/src/test/java/com/hyzq/boss/BossNotificationRouterTest.java` + +- [ ] **Step 1: 写失败测试,约束后台主 Agent 新回复会发通知、重复消息不会重复通知** + +```java +@Test +public void routerNotifiesOnlyForNewMasterAgentRepliesWhileBackgrounded() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + BossAppVisibilityTracker tracker = new BossAppVisibilityTracker(); + tracker.onAppBackgrounded(); + BossNotificationRouter router = new BossNotificationRouter(context, tracker); + + JSONObject message = new JSONObject() + .put("id", "m-2") + .put("sender", "master") + .put("senderLabel", "主 Agent · gpt-5.4-mini") + .put("body", "主 Agent 已完成同步。") + .put("sentAt", "2026-04-21T10:00:00.000Z"); + JSONObject payload = new JSONObject() + .put("projectId", "master-agent") + .put("projectMessagesPayload", new JSONObject().put( + "project", + new JSONObject().put("messages", new JSONArray().put(message)) + )); + + assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload))); + assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload))); +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.BossNotificationRouterTest.routerNotifiesOnlyForNewMasterAgentRepliesWhileBackgrounded` +Expected: FAIL,提示 `BossNotificationRouter` 不存在或断言不满足。 + +- [ ] **Step 3: 写最小实现** + +```java +package com.hyzq.boss; + +import android.Manifest; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.text.TextUtils; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; + +import org.json.JSONArray; +import org.json.JSONObject; + +final class BossNotificationRouter { + static final String CHANNEL_ID = "boss_master_agent_messages"; + static final int MASTER_AGENT_NOTIFICATION_ID = 2001; + + private final Context appContext; + private final BossAppVisibilityTracker visibilityTracker; + private @Nullable String lastNotifiedMessageId; + + BossNotificationRouter(Context context, BossAppVisibilityTracker visibilityTracker) { + this.appContext = context.getApplicationContext(); + this.visibilityTracker = visibilityTracker; + } + + boolean maybeNotifyForRealtimeEvent(@Nullable BossRealtimeEvent event) { + if (!shouldNotify(event)) { + return false; + } + JSONObject latestMessage = latestMasterMessage(event.payload); + if (latestMessage == null) { + return false; + } + String messageId = latestMessage.optString("id", "").trim(); + if (messageId.isEmpty() || messageId.equals(lastNotifiedMessageId)) { + return false; + } + ensureChannel(); + if (!notificationsAllowed()) { + return false; + } + lastNotifiedMessageId = messageId; + NotificationManagerCompat.from(appContext).notify( + MASTER_AGENT_NOTIFICATION_ID, + new NotificationCompat.Builder(appContext, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("主 Agent") + .setContentText(latestMessage.optString("body", "你有一条新回复")) + .setStyle(new NotificationCompat.BigTextStyle().bigText(latestMessage.optString("body", "你有一条新回复"))) + .setAutoCancel(true) + .setContentIntent(buildContentIntent()) + .build() + ); + return true; + } + + private boolean shouldNotify(@Nullable BossRealtimeEvent event) { + if (event == null || !"project.messages.updated".equals(event.eventName)) { + return false; + } + if (!"master-agent".equals(event.payload.optString("projectId", "").trim())) { + return false; + } + if (visibilityTracker.isAppInForeground() && "master-agent".equals(visibilityTracker.getVisibleProjectId())) { + return false; + } + return true; + } + + private @Nullable JSONObject latestMasterMessage(JSONObject payload) { + JSONObject projectPayload = payload.optJSONObject("projectMessagesPayload"); + JSONObject project = projectPayload == null ? null : projectPayload.optJSONObject("project"); + JSONArray messages = project == null ? null : project.optJSONArray("messages"); + if (messages == null || messages.length() == 0) { + return null; + } + JSONObject latest = messages.optJSONObject(messages.length() - 1); + if (latest == null) { + return null; + } + String sender = latest.optString("sender", ""); + String senderLabel = latest.optString("senderLabel", ""); + if (!"master".equals(sender) && !senderLabel.contains("主 Agent")) { + return null; + } + return latest; + } + + private PendingIntent buildContentIntent() { + Intent intent = new Intent(appContext, ProjectDetailActivity.class) + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent") + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + return PendingIntent.getActivity( + appContext, + 901, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + } + + private boolean notificationsAllowed() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + return NotificationManagerCompat.from(appContext).areNotificationsEnabled(); + } + + private void ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager manager = appContext.getSystemService(NotificationManager.class); + if (manager == null || manager.getNotificationChannel(CHANNEL_ID) != null) { + return; + } + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "主 Agent 消息", + NotificationManager.IMPORTANCE_DEFAULT + ); + channel.setDescription("Boss 主 Agent 在后台的新回复通知"); + manager.createNotificationChannel(channel); + } +} +``` + +在 `AndroidManifest.xml` 增加: + +```xml + +``` + +在 `MainActivity.onCreate` 初始化通知路由器,`onResume/onPause` 同步前后台状态,并在 `handleRealtimeEvent` 开头调用: + +```java +notificationRouter.maybeNotifyForRealtimeEvent(event); +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.BossNotificationRouterTest` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add android/app/src/main/java/com/hyzq/boss/BossNotificationRouter.java android/app/src/main/AndroidManifest.xml android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/test/java/com/hyzq/boss/BossNotificationRouterTest.java +git commit -m "feat: notify master agent replies while app is backgrounded" +``` + +### Task 3: 让主 Agent 会话前台抑制通知且不影响现有实时刷新 + +**Files:** +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java` +- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java` +- Modify: `android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java` + +- [ ] **Step 1: 写失败测试,约束前台停留在主 Agent 会话时不发通知,但实时 patch 仍正常落屏** + +```java +@Test +public void masterAgentForegroundConversationSuppressesNotificationButStillProcessesRealtimePatch() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent"); + TestRealtimeProjectDetailActivity activity = Robolectric + .buildActivity(TestRealtimeProjectDetailActivity.class, intent) + .setup() + .resume() + .get(); + + JSONObject payload = new JSONObject() + .put("projectId", "master-agent") + .put("projectMessagesPayload", new JSONObject().put( + "project", + new JSONObject().put("messages", new JSONArray().put( + new JSONObject() + .put("id", "m-2") + .put("sender", "master") + .put("senderLabel", "主 Agent · gpt-5.4-mini") + .put("body", "我已经同步完成。") + )) + )); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from(BossRealtimeEvent.class, new BossRealtimeEvent("project.messages.updated", payload)) + ); + + assertEquals(1, activity.messageReloadCount); + assertEquals(0, activity.emittedNotificationCount); +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityRealtimeTest.masterAgentForegroundConversationSuppressesNotificationButStillProcessesRealtimePatch` +Expected: FAIL,通知抑制钩子还不存在。 + +- [ ] **Step 3: 写最小实现** + +在 `ProjectDetailActivity` 增加可覆写的通知入口: + +```java +void maybeNotifyForRealtimeEvent(BossRealtimeEvent event) { + BossApplication app = (BossApplication) getApplication(); + if (app == null) { + return; + } + app.notificationRouter().maybeNotifyForRealtimeEvent(event); +} +``` + +并在 `handleRealtimeEvent` 的 projectId 校验后、reload 判断前调用: + +```java +maybeNotifyForRealtimeEvent(event); +``` + +同时让 `BossApplication` 提供共享 `BossNotificationRouter`: + +```java +private BossNotificationRouter notificationRouter; + +@Override +public void onCreate() { + ... + notificationRouter = new BossNotificationRouter(this, visibilityTracker); +} + +public BossNotificationRouter notificationRouter() { + return notificationRouter; +} +``` + +`ProjectDetailActivityRealtimeTest.TestRealtimeProjectDetailActivity` 增加测试桩: + +```java +int emittedNotificationCount; + +@Override +void maybeNotifyForRealtimeEvent(BossRealtimeEvent event) { + emittedNotificationCount += 1; +} +``` + +再把测试断言改为:前台主 Agent 会话通过真实 `BossNotificationRouter` 判断后应为 0;普通线程不影响现有 reload 行为。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityRealtimeTest --tests com.hyzq.boss.BossRealtimeClientTest` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add android/app/src/main/java/com/hyzq/boss/BossApplication.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java +git commit -m "test: cover foreground suppression for master agent notifications" +``` + +### Task 4: 真机验证 PLB110 后台通知与前台抑制 + +**Files:** +- Verify only: `android/app/build/outputs/apk/debug/app-debug.apk` + +- [ ] **Step 1: 构建 debug APK** + +Run: `cd android && ./gradlew assembleDebug` +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 2: 安装到 PLB110** + +Run: `adb -s 'adb-HYKVDI4TT4OVAYZT-SHKaaU (2)._adb-tls-connect._tcp' install -r app/build/outputs/apk/debug/app-debug.apk` +Expected: `Success` + +- [ ] **Step 3: 后台通知验证** + +Run: +```bash +adb -s 'adb-HYKVDI4TT4OVAYZT-SHKaaU (2)._adb-tls-connect._tcp' shell monkey -p com.hyzq.boss -c android.intent.category.LAUNCHER 1 +# 进入主 Agent 会话后返回桌面 +# 再用 curl 向 master-agent 触发一条新回复或复用现有联调账号触发主 Agent 回复 +adb -s 'adb-HYKVDI4TT4OVAYZT-SHKaaU (2)._adb-tls-connect._tcp' shell dumpsys notification --noredact | grep -n "主 Agent" +``` +Expected: 后台能看到主 Agent 新通知;点击通知回到主 Agent 会话。 + +- [ ] **Step 4: 前台抑制验证** + +Run: +```bash +# 保持 Boss 前台停留在主 Agent 会话 +# 再触发一条主 Agent 新回复 +adb -s 'adb-HYKVDI4TT4OVAYZT-SHKaaU (2)._adb-tls-connect._tcp' shell dumpsys notification --noredact | grep -n "主 Agent" +``` +Expected: 不新增系统通知,但会话内容实时刷新到界面。 + +- [ ] **Step 5: 汇总并提交** + +```bash +git add android/app/src/main AndroidManifest.xml android/app/src/test/java/com/hyzq/boss +git commit -m "feat: add background notifications for master agent replies" +```