diff --git a/android/app/src/main/java/com/hyzq/boss/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index e940947..4ebd811 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -592,7 +592,7 @@ public class MainActivity extends AppCompatActivity { private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) { if ("conversation.context_indicator.updated".equals(event.eventName)) { - return true; + return false; } if ("conversation.updated".equals(event.eventName)) { return hasProjectId(event) || hasDeviceId(event); diff --git a/docs/superpowers/plans/2026-04-10-android-realtime-noise-reduction.md b/docs/superpowers/plans/2026-04-10-android-realtime-noise-reduction.md new file mode 100644 index 0000000..714d0f2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-android-realtime-noise-reduction.md @@ -0,0 +1,138 @@ +# Android Realtime Noise Reduction 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:** Reduce Android realtime stutter by suppressing heartbeat-only conversation refresh events and avoiding full reloads from initial SSE context snapshots. + +**Architecture:** Keep the existing SSE endpoint and Android `BossRealtimeClient`. Narrow event production in `upsertDeviceHeartbeat` and narrow Android root-page event consumption in `MainActivity`. + +**Tech Stack:** Next.js App Router backend, TypeScript `node:test`, Android Java native client, Server-Sent Events. + +--- + +### Task 1: Lock Heartbeat Event Contract + +**Files:** +- Create: `tests/device-heartbeat-event-noise.test.ts` +- Modify: `src/lib/boss-data.ts` + +- [ ] **Step 1: Write the failing unchanged-heartbeat test** + +```ts +assert.deepEqual(events.map((event) => event.event), ["devices.updated"]); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts` + +Expected: unchanged heartbeat currently emits both `devices.updated` and `conversation.updated`. + +- [ ] **Step 3: Suppress unchanged heartbeat conversation refreshes** + +Track whether heartbeat import/candidate state actually changes the conversation drawer. Always publish `devices.updated`; publish `conversation.updated` only when the drawer changes. + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts` + +Expected: unchanged heartbeat emits only `devices.updated`. + +### Task 2: Keep Import Changes Visible + +**Files:** +- Modify: `tests/device-heartbeat-event-noise.test.ts` +- Modify: `src/lib/boss-data.ts` + +- [ ] **Step 1: Write the changed-candidates test** + +```ts +assert.deepEqual( + events.map((event) => [event.event, event.payload.deviceId, event.payload.note]), + [ + ["devices.updated", "noise-device-b", undefined], + ["conversation.updated", "noise-device-b", "device_import.updated"], + ], +); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts` + +Expected: the event exists before the fix but is too broad and lacks the `device_import.updated` marker. + +- [ ] **Step 3: Publish a marked conversation event only for import changes** + +Use `note: "device_import.updated"` on the conditional `conversation.updated` heartbeat event. + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts` + +Expected: changed candidates publish one marked conversation refresh event. + +### Task 3: Avoid Android Initial Snapshot Full Reloads + +**Files:** +- Create: `tests/android-root-realtime-noise.test.ts` +- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java` + +- [ ] **Step 1: Write the failing Android static test** + +```ts +assert.doesNotMatch( + source, + /"conversation\.context_indicator\.updated"[\s\S]{0,120}return true;/, +); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: `npx tsx --test tests/android-root-realtime-noise.test.ts` + +Expected: `MainActivity` currently returns `true` for the context snapshot event. + +- [ ] **Step 3: Ignore context snapshot as a full reload trigger** + +Change the context snapshot branch to return `false` in `shouldRefreshConversationsTab`. + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: `npx tsx --test tests/android-root-realtime-noise.test.ts` + +Expected: Android root no longer treats initial snapshot events as full reload triggers. + +### Task 4: Verify And Ship + +**Files:** +- Verify: `src/lib/boss-data.ts` +- Verify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java` +- Verify: `tests/device-heartbeat-event-noise.test.ts` +- Verify: `tests/android-root-realtime-noise.test.ts` + +- [ ] **Step 1: Run realtime and device regression tests** + +Run: `npx tsx --test tests/device-heartbeat-event-noise.test.ts tests/android-root-realtime-noise.test.ts tests/device-gui-cli-capabilities.test.ts tests/device-execution-conflict.test.ts tests/conversation-home-items.test.ts tests/realtime-refresh-utils.test.ts tests/project-scoped-realtime-refresh.test.ts tests/android-folder-realtime-refresh.test.ts` + +Expected: all selected tests pass. + +- [ ] **Step 2: Run project gates** + +Run: `npm run lint` + +Expected: lint exits 0. + +Run: `npm run build` + +Expected: production build exits 0. + +- [ ] **Step 3: Build and install Android release APK** + +Run: `cd android && ./gradlew assembleRelease` + +Expected: release APK builds successfully. + +Run: `adb -s install -r android/app/build/outputs/apk/release/app-release.apk` + +Expected: install returns `Success`. diff --git a/docs/superpowers/specs/2026-04-10-android-realtime-noise-reduction-design.md b/docs/superpowers/specs/2026-04-10-android-realtime-noise-reduction-design.md new file mode 100644 index 0000000..6a10d15 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-android-realtime-noise-reduction-design.md @@ -0,0 +1,23 @@ +# Android Realtime Noise Reduction Design + +## Goal + +Reduce Android message receive stutter and reconnect-visible reloads by preventing heartbeat-only SSE traffic from triggering full conversation list refreshes. + +## Evidence + +The production `boss-web` service was active and had no recent service errors. A direct authenticated SSE probe showed the stream stays open with keepalive comments, but every local-agent heartbeat emitted `devices.updated` followed by `conversation.updated` with only `deviceId`. Android `MainActivity` treated that device-only `conversation.updated` as a full conversations tab reload. The stream also sends an initial `conversation.context_indicator.updated` snapshot on connection; Android was not consuming its payload directly and instead used it as another full reload trigger. + +## Design + +Keep `devices.updated` for every heartbeat so device status remains fresh. Stop publishing `conversation.updated` for unchanged device heartbeats. Publish a single `conversation.updated` with `note: "device_import.updated"` only when heartbeat candidate/import state actually changes the conversation drawer structure. + +On Android, do not use the initial `conversation.context_indicator.updated` snapshot as a full list reload trigger. Actual conversation and project message events still refresh the relevant views, while the existing root-page auto-refresh remains the low-frequency fallback for aggregate context status. + +## Testing + +Add event-contract tests proving unchanged heartbeats only emit `devices.updated`, while changed import candidates emit one `conversation.updated` marked as `device_import.updated`. Add an Android static test proving `MainActivity` no longer returns `true` for the initial context snapshot refresh path. + +## Out Of Scope + +This change does not replace SSE with a different transport, does not remove project-scoped message events, and does not implement payload-level in-place updates for Android conversation rows. diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 3b1a0b8..990c1de 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -7648,6 +7648,7 @@ export async function upsertDeviceHeartbeat(payload: { }>; }) { const result = await mutateState((state) => { + let conversationRefreshRequired = false; const projectUnderstandingSyncRequests: Array<{ projectId: string; observedActivityAt: string; @@ -7761,6 +7762,7 @@ export async function upsertDeviceHeartbeat(payload: { } else if (!existing.deviceIds.includes(payload.deviceId)) { existing.deviceIds.push(payload.deviceId); existing.isGroup = existing.deviceIds.length > 1; + conversationRefreshRequired = true; } } } @@ -7896,6 +7898,7 @@ export async function upsertDeviceHeartbeat(payload: { pruneMissingCandidates: true, }); draft = applied.draft; + conversationRefreshRequired = true; } } @@ -7905,13 +7908,16 @@ export async function upsertDeviceHeartbeat(payload: { pairingStatus: claimedEnrollment?.status, importDraft: draft, projectUnderstandingSyncRequests, + conversationRefreshRequired, }; }); for (const request of result.projectUnderstandingSyncRequests ?? []) { await queueProjectUnderstandingSyncTask(request); } publishBossEvent("devices.updated", { deviceId: payload.deviceId }); - publishBossEvent("conversation.updated", { deviceId: payload.deviceId }); + if (result.conversationRefreshRequired) { + publishBossEvent("conversation.updated", { deviceId: payload.deviceId, note: "device_import.updated" }); + } return result; } @@ -7994,6 +8000,13 @@ function shouldAutoSyncHeartbeatCandidates(input: { if (!input.wasExistingDevice) return false; if (input.device.source !== "production") return false; if (!input.draft || input.draft.candidates.length === 0) return false; + if ( + input.draft.status === "applied" && + input.draft.resolutionId && + sameStringSet(input.draft.selectedCandidateIds, resolveAutoSyncCandidateIds(input.draft)) + ) { + return false; + } if ( input.claimedEnrollment?.enrollmentId && input.draft.enrollmentId === input.claimedEnrollment.enrollmentId diff --git a/tests/android-root-realtime-noise.test.ts b/tests/android-root-realtime-noise.test.ts new file mode 100644 index 0000000..5b96767 --- /dev/null +++ b/tests/android-root-realtime-noise.test.ts @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +test("android root page does not force a full list refresh for initial context snapshots", async () => { + const source = await readFile( + new URL("../android/app/src/main/java/com/hyzq/boss/MainActivity.java", import.meta.url), + "utf8", + ); + + assert.doesNotMatch( + source, + /"conversation\.context_indicator\.updated"[\s\S]{0,120}return true;/, + "context indicator snapshots are payload-only hints and should not trigger a full Android list reload", + ); +}); diff --git a/tests/device-heartbeat-event-noise.test.ts b/tests/device-heartbeat-event-noise.test.ts new file mode 100644 index 0000000..91681f9 --- /dev/null +++ b/tests/device-heartbeat-event-noise.test.ts @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; + +let runtimeRoot = ""; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"]; +let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"]; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-heartbeat-noise-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [data, events] = await Promise.all([ + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-events.ts"), + ]); + readState = data.readState; + upsertDeviceHeartbeat = data.upsertDeviceHeartbeat; + subscribeBossEvents = events.subscribeBossEvents; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +function buildHeartbeatPayload(deviceId: string, projectCandidates: Array<{ + folderName: string; + threadId: string; + threadDisplayName: string; + codexFolderRef: string; + codexThreadRef: string; + lastActiveAt: string; +}>) { + return { + deviceId, + token: `${deviceId}-token`, + name: "Mac Studio", + avatar: "M", + account: "17600003315", + status: "online" as const, + quota5h: 68, + quota7d: 81, + projects: [], + endpoint: "mac://kris.local", + projectCandidates, + }; +} + +const bossThreadCandidate = { + folderName: "boss", + threadId: "thread-boss-main", + threadDisplayName: "Boss开发主线程", + codexFolderRef: "/Users/kris/code/boss", + codexThreadRef: "thread-boss-main", + lastActiveAt: "2026-04-10T10:00:00.000Z", +}; + +test("unchanged device heartbeats do not publish conversation refresh events", async () => { + await setup(); + + const heartbeat = buildHeartbeatPayload("noise-device-a", [bossThreadCandidate]); + await upsertDeviceHeartbeat(heartbeat); + await upsertDeviceHeartbeat(heartbeat); + + const primedState = await readState(); + const primedDraft = primedState.deviceImportDrafts.find((draft) => draft.deviceId === "noise-device-a"); + assert.equal(primedDraft?.status, "applied"); + + const events: Array<{ event: string; payload: { note?: string; deviceId?: string } }> = []; + const unsubscribe = subscribeBossEvents((event, payload) => { + events.push({ event, payload }); + }); + await upsertDeviceHeartbeat(heartbeat); + unsubscribe(); + + assert.deepEqual(events.map((event) => event.event), ["devices.updated"]); +}); + +test("device heartbeats publish one conversation refresh when import candidates change", async () => { + await setup(); + + const nextThreadCandidate = { + folderName: "boss", + threadId: "thread-boss-review", + threadDisplayName: "Boss回归线程", + codexFolderRef: "/Users/kris/code/boss", + codexThreadRef: "thread-boss-review", + lastActiveAt: "2026-04-10T10:01:00.000Z", + }; + + await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate])); + await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate])); + + const events: Array<{ event: string; payload: { note?: string; deviceId?: string } }> = []; + const unsubscribe = subscribeBossEvents((event, payload) => { + events.push({ event, payload }); + }); + await upsertDeviceHeartbeat(buildHeartbeatPayload("noise-device-b", [bossThreadCandidate, nextThreadCandidate])); + unsubscribe(); + + assert.deepEqual( + events.map((event) => [event.event, event.payload.deviceId, event.payload.note]), + [ + ["devices.updated", "noise-device-b", undefined], + ["conversation.updated", "noise-device-b", "device_import.updated"], + ], + ); +});