docs: add master agent background notification plan

This commit is contained in:
kris
2026-04-21 16:26:10 +08:00
parent a9ed7c911d
commit 916528de2b

View File

@@ -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
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```
`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"
```