Files
boss/docs/superpowers/plans/2026-04-21-master-agent-background-notification.md

18 KiB
Raw Permalink Blame History

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 的实时事件抛给界面层,MainActivityProjectDetailActivity 在收到 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: 写失败测试,约束前后台和当前会话可见性状态

@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: 写最小实现
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 中挂单例:

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;
    }
}

ProjectDetailActivityonResume/onPause 中同步当前会话:

@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 新回复会发通知、重复消息不会重复通知

@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: 写最小实现
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 增加:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

MainActivity.onCreate 初始化通知路由器,onResume/onPause 同步前后台状态,并在 handleRealtimeEvent 开头调用:

notificationRouter.maybeNotifyForRealtimeEvent(event);
  • Step 4: 运行测试确认通过

Run: cd android && ./gradlew testDebugUnitTest --tests com.hyzq.boss.BossNotificationRouterTest Expected: PASS

  • Step 5: 提交
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 仍正常落屏

@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 增加可覆写的通知入口:

void maybeNotifyForRealtimeEvent(BossRealtimeEvent event) {
    BossApplication app = (BossApplication) getApplication();
    if (app == null) {
        return;
    }
    app.notificationRouter().maybeNotifyForRealtimeEvent(event);
}

并在 handleRealtimeEvent 的 projectId 校验后、reload 判断前调用:

maybeNotifyForRealtimeEvent(event);

同时让 BossApplication 提供共享 BossNotificationRouter

private BossNotificationRouter notificationRouter;

@Override
public void onCreate() {
    ...
    notificationRouter = new BossNotificationRouter(this, visibilityTracker);
}

public BossNotificationRouter notificationRouter() {
    return notificationRouter;
}

ProjectDetailActivityRealtimeTest.TestRealtimeProjectDetailActivity 增加测试桩:

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: 提交
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:

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:

# 保持 Boss 前台停留在主 Agent 会话
# 再触发一条主 Agent 新回复
adb -s 'adb-HYKVDI4TT4OVAYZT-SHKaaU (2)._adb-tls-connect._tcp' shell dumpsys notification --noredact | grep -n "主 Agent"

Expected: 不新增系统通知,但会话内容实时刷新到界面。

  • Step 5: 汇总并提交
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"