From 5a53b60f133b470bee6758a0add70be5856fd4fc Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 5 Apr 2026 03:23:11 +0800 Subject: [PATCH] feat: narrow thread sync context and dedupe realtime refresh --- .../com/hyzq/boss/BossRealtimeClient.java | 66 +++++ .../main/java/com/hyzq/boss/MainActivity.java | 69 +++++- .../com/hyzq/boss/ProjectDetailActivity.java | 49 +++- .../com/hyzq/boss/BossRealtimeClientTest.java | 10 + .../hyzq/boss/MainActivityRealtimeTest.java | 71 ++++++ .../ProjectDetailActivityRealtimeTest.java | 103 ++++++++ src/lib/boss-data.ts | 17 +- src/lib/boss-master-agent.ts | 148 +++++++++--- .../master-agent-thread-status-prompt.test.ts | 228 ++++++++++++++++-- 9 files changed, 678 insertions(+), 83 deletions(-) diff --git a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java index f34a9fe..22281fa 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java @@ -3,6 +3,7 @@ package com.hyzq.boss; import androidx.annotation.Nullable; import org.json.JSONException; +import org.json.JSONArray; import org.json.JSONObject; import java.io.BufferedReader; @@ -10,6 +11,9 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; import java.nio.charset.StandardCharsets; final class BossRealtimeClient { @@ -19,6 +23,7 @@ final class BossRealtimeClient { private final BossApiClient apiClient; private final Listener listener; + private static final String HEARTBEAT_EVENT_NAME = "heartbeat"; private volatile boolean running; private @Nullable Thread workerThread; private @Nullable HttpURLConnection activeConnection; @@ -144,6 +149,9 @@ final class BossRealtimeClient { if (eventName.isEmpty()) { return null; } + if (HEARTBEAT_EVENT_NAME.equals(eventName)) { + return null; + } JSONObject payload = new JSONObject(); if (dataBuilder.length() > 0) { try { @@ -151,7 +159,65 @@ final class BossRealtimeClient { } catch (JSONException ignored) { payload = new JSONObject(); } + } else { + return null; } return new BossRealtimeEvent(eventName, payload); } + + static String buildEventFingerprint(@Nullable BossRealtimeEvent event) { + if (event == null || event.eventName.isEmpty()) { + return ""; + } + return event.eventName + "|" + canonicalizeJson(event.payload); + } + + private static String canonicalizeJson(@Nullable Object value) { + if (value == null || value == JSONObject.NULL) { + return "null"; + } + if (value instanceof JSONObject) { + JSONObject object = (JSONObject) value; + ArrayList keys = new ArrayList<>(); + Iterator iterator = object.keys(); + while (iterator.hasNext()) { + String key = iterator.next(); + if (!"at".equals(key)) { + keys.add(key); + } + } + Collections.sort(keys); + StringBuilder builder = new StringBuilder("{"); + for (int index = 0; index < keys.size(); index += 1) { + if (index > 0) { + builder.append(','); + } + String key = keys.get(index); + builder.append(JSONObject.quote(key)); + builder.append(':'); + builder.append(canonicalizeJson(object.opt(key))); + } + builder.append('}'); + return builder.toString(); + } + if (value instanceof JSONArray) { + JSONArray array = (JSONArray) value; + StringBuilder builder = new StringBuilder("["); + for (int index = 0; index < array.length(); index += 1) { + if (index > 0) { + builder.append(','); + } + builder.append(canonicalizeJson(array.opt(index))); + } + builder.append(']'); + return builder.toString(); + } + if (value instanceof String) { + return JSONObject.quote((String) value); + } + if (value instanceof Number || value instanceof Boolean) { + return String.valueOf(value); + } + return JSONObject.quote(String.valueOf(value)); + } } 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 6f1e915..bfddc71 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -102,7 +102,7 @@ public class MainActivity extends AppCompatActivity { private boolean conversationQuickActionsVisible = false; private boolean conversationAutoRefreshArmed = false; private boolean conversationAutoRefreshEnabled = false; - private long lastRealtimeRefreshAt = 0L; + private final java.util.HashMap recentRealtimeEventTimestamps = new java.util.HashMap<>(); private final Set selectedConversationProjectIds = new LinkedHashSet<>(); private @Nullable RootPagerAdapter rootPagerAdapter; private boolean syncingRootPagerSelection = false; @@ -372,30 +372,73 @@ public class MainActivity extends AppCompatActivity { } boolean shouldRefresh = false; if ("conversations".equals(activeTab)) { - shouldRefresh = - "conversation.updated".equals(event.eventName) || - "project.messages.updated".equals(event.eventName) || - "master_agent.task.updated".equals(event.eventName) || - "conversation.context_indicator.updated".equals(event.eventName); + shouldRefresh = shouldRefreshConversationsTab(event); } else if ("devices".equals(activeTab)) { - shouldRefresh = - "devices.updated".equals(event.eventName) || - "devices.skills.updated".equals(event.eventName) || - "conversation.updated".equals(event.eventName); + shouldRefresh = shouldRefreshDevicesTab(event); } else if ("me".equals(activeTab)) { shouldRefresh = "ota.updated".equals(event.eventName) || "app.logs.updated".equals(event.eventName); } if (!shouldRefresh) { return; } - long now = System.currentTimeMillis(); - if (now - lastRealtimeRefreshAt < REALTIME_REFRESH_THROTTLE_MS) { + String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event); + if (eventFingerprint.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + if (isDuplicateRealtimeEvent(eventFingerprint, now)) { return; } - lastRealtimeRefreshAt = now; runOnUiThread(this::refreshCurrentTab); } + private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { + pruneRecentRealtimeEvents(now); + Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); + if (previousEventAt != null && now - previousEventAt < REALTIME_REFRESH_THROTTLE_MS) { + return true; + } + recentRealtimeEventTimestamps.put(eventFingerprint, now); + return false; + } + + private void pruneRecentRealtimeEvents(long now) { + java.util.Iterator> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + java.util.Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_REFRESH_THROTTLE_MS) { + iterator.remove(); + } + } + } + + private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) { + if (!hasProjectId(event)) { + return false; + } + return "conversation.updated".equals(event.eventName) + || "project.messages.updated".equals(event.eventName) + || "master_agent.task.updated".equals(event.eventName) + || "conversation.context_indicator.updated".equals(event.eventName); + } + + private boolean shouldRefreshDevicesTab(BossRealtimeEvent event) { + if (!hasDeviceId(event)) { + return false; + } + return "devices.updated".equals(event.eventName) + || "devices.skills.updated".equals(event.eventName) + || "conversation.updated".equals(event.eventName); + } + + private boolean hasProjectId(BossRealtimeEvent event) { + return event != null && !event.payload.optString("projectId", "").trim().isEmpty(); + } + + private boolean hasDeviceId(BossRealtimeEvent event) { + return event != null && !event.payload.optString("deviceId", "").trim().isEmpty(); + } + private void refreshAllData(@Nullable JSONObject initialSession) { startRefreshing(true); topSubtitle.setText(""); diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index 8f06d30..2d0fc15 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -84,7 +84,7 @@ public class ProjectDetailActivity extends BossScreenActivity { private ActivityResultLauncher filePickerLauncher; private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor(); private @Nullable BossRealtimeClient realtimeClient; - private long lastRealtimeReloadAt; + private final java.util.HashMap recentRealtimeEventTimestamps = new java.util.HashMap<>(); static final class ChromeBindings { final boolean multiSelecting; @@ -276,24 +276,51 @@ public class ProjectDetailActivity extends BossScreenActivity { if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) { return; } - boolean shouldReload = false; - String payloadProjectId = event.payload.optString("projectId", ""); - if ("project.messages.updated".equals(event.eventName) || "conversation.updated".equals(event.eventName)) { - shouldReload = projectId.equals(payloadProjectId); - } else if ("master_agent.task.updated".equals(event.eventName)) { - shouldReload = "master-agent".equals(projectId); - } + boolean shouldReload = shouldReloadForRealtimeEvent(event); if (!shouldReload) { return; } - long now = System.currentTimeMillis(); - if (now - lastRealtimeReloadAt < REALTIME_RELOAD_THROTTLE_MS) { + String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event); + if (eventFingerprint.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + if (isDuplicateRealtimeEvent(eventFingerprint, now)) { return; } - lastRealtimeReloadAt = now; runOnUiThread(this::triggerRealtimeReload); } + private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { + pruneRecentRealtimeEvents(now); + Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint); + if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) { + return true; + } + recentRealtimeEventTimestamps.put(eventFingerprint, now); + return false; + } + + private void pruneRecentRealtimeEvents(long now) { + java.util.Iterator> iterator = recentRealtimeEventTimestamps.entrySet().iterator(); + while (iterator.hasNext()) { + java.util.Map.Entry entry = iterator.next(); + if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) { + iterator.remove(); + } + } + } + + private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) { + String payloadProjectId = event.payload.optString("projectId", "").trim(); + if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) { + return false; + } + return "project.messages.updated".equals(event.eventName) + || "conversation.updated".equals(event.eventName) + || "master_agent.task.updated".equals(event.eventName); + } + void triggerRealtimeReload() { reload(); } diff --git a/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java b/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java index c0ed89a..79e22d1 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossRealtimeClientTest.java @@ -27,4 +27,14 @@ public class BossRealtimeClientTest { public void parseEventBlockReturnsNullForKeepaliveComment() { assertNull(BossRealtimeClient.parseEventBlock(": keepalive\n\n")); } + + @Test + public void parseEventBlockIgnoresHeartbeatControlEvents() { + assertNull(BossRealtimeClient.parseEventBlock("event: heartbeat\n\n")); + } + + @Test + public void parseEventBlockReturnsNullForEmptyEventPayloads() { + assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n")); + } } diff --git a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java index 0e5d9c5..2c58d4c 100644 --- a/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/MainActivityRealtimeTest.java @@ -7,6 +7,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.util.ReflectionHelpers; @@ -25,6 +26,7 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1")) ) ); + Shadows.shadowOf(activity.getMainLooper()).idle(); assertEquals(1, activity.refreshCount); } @@ -41,10 +43,79 @@ public class MainActivityRealtimeTest { new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio")) ) ); + Shadows.shadowOf(activity.getMainLooper()).idle(); assertEquals(0, activity.refreshCount); } + @Test + public void blankProjectIdConversationEventDoesNotRefreshVisibleConversationTab() throws Exception { + TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("conversation.updated", new JSONObject()) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(0, activity.refreshCount); + } + + @Test + public void contextIndicatorEventRefreshesVisibleConversationTab() throws Exception { + TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent( + "conversation.context_indicator.updated", + new JSONObject().put("projectId", "project-1") + ) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.refreshCount); + } + + @Test + public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception { + TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); + ReflectionHelpers.callInstanceMethod(activity, "showContent"); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent( + "conversation.updated", + new JSONObject().put("projectId", "project-1").put("at", "2026-04-05T10:00:00.000Z") + ) + ) + ); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent( + "project.messages.updated", + new JSONObject().put("projectId", "project-1").put("at", "2026-04-05T10:00:00.500Z") + ) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(2, activity.refreshCount); + } + public static class TestMainActivity extends MainActivity { int refreshCount; diff --git a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java index 7b38fbd..981e252 100644 --- a/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java +++ b/android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityRealtimeTest.java @@ -9,6 +9,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.util.ReflectionHelpers; @@ -34,6 +35,7 @@ public class ProjectDetailActivityRealtimeTest { new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1")) ) ); + Shadows.shadowOf(activity.getMainLooper()).idle(); assertEquals(1, activity.reloadCount); } @@ -57,10 +59,111 @@ public class ProjectDetailActivityRealtimeTest { new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2")) ) ); + Shadows.shadowOf(activity.getMainLooper()).idle(); assertEquals(0, activity.reloadCount); } + @Test + public void masterAgentTaskEventDoesNotRefreshForDifferentProjectId() 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(); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2")) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(0, activity.reloadCount); + } + + @Test + public void distinctRealtimeEventsBackToBackStillReloadMatchingProject() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线"); + TestRealtimeProjectDetailActivity activity = Robolectric + .buildActivity(TestRealtimeProjectDetailActivity.class, intent) + .setup() + .resume() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent( + "conversation.updated", + new JSONObject().put("projectId", "project-1").put("at", "2026-04-05T10:00:00.000Z") + ) + ) + ); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent( + "project.messages.updated", + new JSONObject().put("projectId", "project-1").put("at", "2026-04-05T10:00:00.500Z") + ) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(2, activity.reloadCount); + } + + @Test + public void duplicateRealtimeEventsWithDifferentAtAreDeduped() throws Exception { + Intent intent = new Intent() + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") + .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线"); + TestRealtimeProjectDetailActivity activity = Robolectric + .buildActivity(TestRealtimeProjectDetailActivity.class, intent) + .setup() + .resume() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent( + "project.messages.updated", + new JSONObject().put("projectId", "project-1").put("at", "2026-04-05T10:00:00.000Z") + ) + ) + ); + ReflectionHelpers.callInstanceMethod( + activity, + "handleRealtimeEvent", + ReflectionHelpers.ClassParameter.from( + BossRealtimeEvent.class, + new BossRealtimeEvent( + "project.messages.updated", + new JSONObject().put("projectId", "project-1").put("at", "2026-04-05T10:00:00.500Z") + ) + ) + ); + Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.reloadCount); + } + public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity { int reloadCount; diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 29059fc..00cb00a 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -7161,7 +7161,7 @@ export async function upsertDeviceHeartbeat(payload: { sourceTaskId: `heartbeat-${candidate.candidateId}`, }); } - if (shouldQueueProjectUnderstandingSync(matchingProject, candidate.lastActiveAt, state)) { + if (shouldQueueProjectUnderstandingSync(matchingProject, candidate.lastActiveAt, state, "heartbeat_activity")) { projectUnderstandingSyncRequests.push({ projectId: matchingProject.id, observedActivityAt: candidate.lastActiveAt, @@ -7601,7 +7601,12 @@ function buildProjectUnderstandingTakeoverNotice(projectName: string, snapshot: .join("\n"); } -function shouldQueueProjectUnderstandingSync(project: Project, observedActivityAt: string, state: BossState) { +function shouldQueueProjectUnderstandingSync( + project: Project, + observedActivityAt: string, + state: BossState, + reason: "heartbeat_activity" | "thread_reply" = "heartbeat_activity", +) { if (!isDispatchableThreadProject(project)) { return false; } @@ -7621,6 +7626,9 @@ function shouldQueueProjectUnderstandingSync(project: Project, observedActivityA const hasThreadStatusDocument = state.threadStatusDocuments.some( (item) => item.projectId === project.id && item.threadId === project.threadMeta.threadId, ); + if (reason === "thread_reply" && hasThreadStatusDocument) { + return false; + } if (project.projectUnderstanding && hasThreadStatusDocument) { const lastSyncedTs = Date.parse( project.threadMeta.lastProjectUnderstandingSyncedAt ?? @@ -7673,7 +7681,7 @@ async function queueProjectUnderstandingSyncTask(input: { }) { const state = await readState(); const project = state.projects.find((item) => item.id === input.projectId); - if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state)) { + if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state, input.reason)) { return null; } const requestedByAccount = state.user.account || project.deviceIds[0] || "17600003315"; @@ -8625,7 +8633,8 @@ export async function appendProjectMessage(payload: { return { message, shouldQueueUnderstandingSync: - shouldTrackThreadProgress && shouldQueueProjectUnderstandingSync(project, message.sentAt, state), + shouldTrackThreadProgress && + shouldQueueProjectUnderstandingSync(project, message.sentAt, state, "thread_reply"), }; }); if (result.shouldQueueUnderstandingSync) { diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index fdc7224..cf8e049 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -261,35 +261,10 @@ function buildRuntimeDigest( .filter((update) => update.status === "available") .map((update) => `${update.version} -> ${update.targetScope}`) .join("\n"); - const threadStatusDocuments = [...state.threadStatusDocuments] - .sort((left, right) => { - const updatedDelta = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); - if (updatedDelta !== 0) { - return updatedDelta; - } - return right.documentId.localeCompare(left.documentId); - }) - .slice(0, 6) - .map((document) => buildThreadStatusDocumentDigest(state, document)); - const recentProgressEvents = [...state.threadProgressEvents] - .sort((left, right) => { - const createdDelta = Date.parse(right.createdAt) - Date.parse(left.createdAt); - if (createdDelta !== 0) { - return createdDelta; - } - return right.eventId.localeCompare(left.eventId); - }) - .slice(0, 8) - .map((event) => buildThreadProgressEventDigest(state, event)); - const deepPullThreadUnderstandings = state.projects - .filter((project) => project.id !== "master-agent" && project.projectUnderstanding) - .sort((left, right) => - String(right.projectUnderstanding?.updatedAt ?? right.lastMessageAt).localeCompare( - String(left.projectUnderstanding?.updatedAt ?? left.lastMessageAt), - ), - ) - .slice(0, 3) - .map((project) => buildDeepPullThreadUnderstandingDigest(project)); + const threadRuntimeSelection = selectThreadRuntimeDigestSelection(state, requestText); + const threadStatusDocuments = threadRuntimeSelection.threadStatusDocuments; + const recentProgressEvents = threadRuntimeSelection.recentProgressEvents; + const deepPullThreadUnderstandings = threadRuntimeSelection.deepPullThreadUnderstandings; const authSummary = [ `登录会话策略:成功登录后默认保持 ${Math.round(AUTH_SESSION_TTL_MS / 24 / 60 / 60_000)} 天。`, @@ -309,9 +284,13 @@ function buildRuntimeDigest( "最近进展事件:", recentProgressEvents.length > 0 ? recentProgressEvents.join("\n") : "无", "", - "关键时刻深拉线程兜底:", - deepPullThreadUnderstandings.length > 0 ? deepPullThreadUnderstandings.join("\n") : "无", - "", + ...(deepPullThreadUnderstandings.length > 0 + ? [ + "关键时刻深拉线程兜底:", + deepPullThreadUnderstandings.join("\n"), + "", + ] + : []), "最近主 Agent 对话:", recentMessages || "无", "", @@ -332,6 +311,111 @@ function buildRuntimeDigest( ].join("\n"); } +function selectThreadRuntimeDigestSelection( + state: Awaited>, + requestText: string, +) { + const projectsWithRuntimeEvidence = state.projects + .filter((project) => + state.threadStatusDocuments.some((document) => document.projectId === project.id) || + state.threadProgressEvents.some((event) => event.projectId === project.id), + ) + .sort((left, right) => compareProjectRuntimeDigestActivity(right, left)); + + const scoredProjects = state.projects + .map((project) => ({ + project, + score: scoreMasterAgentDispatchCandidate(project, requestText), + })) + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return compareProjectRuntimeDigestActivity(right.project, left.project); + }); + + const matchedProjects = scoredProjects.filter((item) => item.score > 0).map((item) => item.project); + const matchedNonMasterProjects = matchedProjects.filter((project) => project.id !== "master-agent"); + const selectedProjects = + matchedNonMasterProjects.length > 0 + ? matchedNonMasterProjects + : matchedProjects.length > 0 + ? matchedProjects + : projectsWithRuntimeEvidence.slice(0, 3); + + let selectedProjectIds = new Set(selectedProjects.map((project) => project.id)); + let threadStatusDocuments = [...state.threadStatusDocuments] + .filter((document) => selectedProjectIds.has(document.projectId)) + .sort((left, right) => { + const updatedDelta = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + if (updatedDelta !== 0) { + return updatedDelta; + } + return right.documentId.localeCompare(left.documentId); + }); + let recentProgressEvents = [...state.threadProgressEvents] + .filter((event) => selectedProjectIds.has(event.projectId)) + .sort((left, right) => { + const createdDelta = Date.parse(right.createdAt) - Date.parse(left.createdAt); + if (createdDelta !== 0) { + return createdDelta; + } + return right.eventId.localeCompare(left.eventId); + }); + + if (threadStatusDocuments.length === 0 && recentProgressEvents.length === 0 && projectsWithRuntimeEvidence.length > 0) { + selectedProjectIds = new Set(projectsWithRuntimeEvidence.slice(0, 3).map((project) => project.id)); + threadStatusDocuments = [...state.threadStatusDocuments] + .filter((document) => selectedProjectIds.has(document.projectId)) + .sort((left, right) => { + const updatedDelta = Date.parse(right.updatedAt) - Date.parse(left.updatedAt); + if (updatedDelta !== 0) { + return updatedDelta; + } + return right.documentId.localeCompare(left.documentId); + }); + recentProgressEvents = [...state.threadProgressEvents] + .filter((event) => selectedProjectIds.has(event.projectId)) + .sort((left, right) => { + const createdDelta = Date.parse(right.createdAt) - Date.parse(left.createdAt); + if (createdDelta !== 0) { + return createdDelta; + } + return right.eventId.localeCompare(left.eventId); + }); + } + + const deepPullThreadUnderstandings = + threadStatusDocuments.length === 0 && recentProgressEvents.length === 0 && projectsWithRuntimeEvidence.length === 0 + ? state.projects + .filter((project) => project.id !== "master-agent" && project.projectUnderstanding) + .sort((left, right) => compareProjectRuntimeDigestActivity(right, left)) + .slice(0, 3) + .map((project) => buildDeepPullThreadUnderstandingDigest(project)) + .filter((entry): entry is string => Boolean(entry)) + : []; + + return { + threadStatusDocuments: threadStatusDocuments.slice(0, 6).map((document) => buildThreadStatusDocumentDigest(state, document)), + recentProgressEvents: recentProgressEvents.slice(0, 8).map((event) => buildThreadProgressEventDigest(state, event)), + deepPullThreadUnderstandings, + }; +} + +function compareProjectRuntimeDigestActivity(left: Project, right: Project) { + return projectRuntimeDigestActivityValue(left) - projectRuntimeDigestActivityValue(right); +} + +function projectRuntimeDigestActivityValue(project: Project) { + return Math.max( + Date.parse(project.updatedAt || ""), + Date.parse(project.lastMessageAt || ""), + Date.parse(project.threadMeta.updatedAt || ""), + Date.parse(project.threadMeta.lastObservedCodexActivityAt || ""), + Date.parse(project.projectUnderstanding?.updatedAt || ""), + ); +} + function buildThreadStatusDocumentDigest( state: Awaited>, document: Awaited>["threadStatusDocuments"][number], diff --git a/tests/master-agent-thread-status-prompt.test.ts b/tests/master-agent-thread-status-prompt.test.ts index ba12bd2..219363c 100644 --- a/tests/master-agent-thread-status-prompt.test.ts +++ b/tests/master-agent-thread-status-prompt.test.ts @@ -44,7 +44,7 @@ test.after(async () => { } }); -test("主 Agent 执行 prompt 默认读取线程状态文档、最近进展事件和项目记忆,并保留深拉兜底", async () => { +test("主 Agent 执行 prompt 命中线程时只读取相关状态文档和最近进展事件,不再常态注入深拉兜底", async () => { await setup(); await saveAiAccount({ @@ -90,6 +90,8 @@ test("主 Agent 执行 prompt 默认读取线程状态文档、最近进展事 const state = await readState(); const auditProject = state.projects.find((project) => project.id === "audit-collab"); assert.ok(auditProject, "expected seeded audit-collab project"); + const masterProject = state.projects.find((project) => project.id === "master-agent"); + assert.ok(masterProject, "expected seeded master-agent project"); auditProject!.projectUnderstanding = { projectGoal: "深拉兜底目标", currentProgress: "深拉兜底进度", @@ -100,7 +102,36 @@ test("主 Agent 执行 prompt 默认读取线程状态文档、最近进展事 updatedAt: "2026-04-04T18:00:00+08:00", sourceKind: "thread_sync", }; + masterProject!.projectUnderstanding = { + projectGoal: "主 Agent 旧目标", + currentProgress: "主 Agent 旧进度", + technicalArchitecture: "主 Agent 旧架构", + currentBlockers: "主 Agent 旧阻塞", + recommendedNextStep: "主 Agent 旧下一步", + sourceTaskId: "task-master-legacy", + updatedAt: "2026-04-04T17:50:00+08:00", + sourceKind: "thread_sync", + }; state.threadStatusDocuments = [ + { + documentId: "thread-status-doc-master", + projectId: "master-agent", + threadId: "thread-master-main", + threadDisplayName: "主 Agent 汇总", + folderName: "主控线程", + deviceId: "mac-studio", + projectGoal: "主 Agent 额外状态目标", + currentPhase: "主 Agent 额外阶段", + currentProgress: "主 Agent 额外进度", + technicalArchitecture: "主 Agent 额外架构", + currentBlockers: "主 Agent 额外阻塞", + recommendedNextStep: "主 Agent 额外下一步", + keyFiles: ["src/lib/boss-master-agent.ts"], + keyCommands: ["npm run lint"], + updatedAt: "2026-04-04T18:03:00+08:00", + sourceTaskId: "task-master-status", + sourceKind: "incremental_sync", + }, { documentId: "thread-status-doc-1", projectId: "audit-collab", @@ -122,6 +153,20 @@ test("主 Agent 执行 prompt 默认读取线程状态文档、最近进展事 }, ]; state.threadProgressEvents = [ + { + eventId: "thread-progress-event-master", + projectId: "master-agent", + threadId: "thread-master-main", + threadDisplayName: "主 Agent 汇总", + deviceId: "mac-studio", + eventType: "progress_updated", + summary: "主 Agent 额外进展摘要", + phase: "主 Agent 额外阶段", + blockerDelta: "主 Agent 额外阻塞", + nextStepDelta: "主 Agent 额外下一步", + createdAt: "2026-04-04T18:03:30+08:00", + sourceTaskId: "task-master-progress", + }, { eventId: "thread-progress-event-1", projectId: "audit-collab", @@ -142,7 +187,7 @@ test("主 Agent 执行 prompt 默认读取线程状态文档、最近进展事 const resolved = await resolveMasterAgentExecutionConfig( "master-agent", "17600003315", - "继续推进线程状态同步", + "审计对话,请继续推进线程状态同步", ); assert.ok(resolved.projectMemories.length > 0); assert.equal(resolved.projectMemories[0]?.content, "项目记忆正文"); @@ -157,7 +202,7 @@ test("主 Agent 执行 prompt 默认读取线程状态文档、最近进展事 ); const reply = await replyToMasterAgentUserMessage({ - requestText: "继续推进线程状态同步", + requestText: "审计对话,请继续推进线程状态同步", requestedBy: "Boss 超级管理员", requestedByAccount: "17600003315", mode: "enqueue", @@ -166,32 +211,169 @@ test("主 Agent 执行 prompt 默认读取线程状态文档、最近进展事 assert.equal(reply.masterReplyState, "queued"); const queuedTask = (await readState()).masterAgentTasks.find( - (task) => task.projectId === "master-agent" && task.requestText === "继续推进线程状态同步", + (task) => task.projectId === "master-agent" && task.requestText === "审计对话,请继续推进线程状态同步", ); assert.ok(queuedTask, "expected master-agent task to be queued"); assert.ok(queuedTask?.executionPrompt.includes("线程状态文档:")); assert.ok(queuedTask?.executionPrompt.includes("线程状态目标")); + assert.ok(!queuedTask?.executionPrompt.includes("主 Agent 额外状态目标")); assert.ok(queuedTask?.executionPrompt.includes("最近进展事件:")); assert.ok(queuedTask?.executionPrompt.includes("最近进展事件摘要")); + assert.ok(!queuedTask?.executionPrompt.includes("主 Agent 额外进展摘要")); + assert.ok(!queuedTask?.executionPrompt.includes("深拉兜底目标")); + assert.ok(!queuedTask?.executionPrompt.includes("关键时刻深拉线程兜底:")); +}); + +test("主 Agent 执行 prompt 在未命中时退回最近活跃项目,且不会常态注入深拉兜底", async () => { + await setup(); + + const state = await readState(); + const auditProject = state.projects.find((project) => project.id === "audit-collab"); + const masterProject = state.projects.find((project) => project.id === "master-agent"); + assert.ok(auditProject, "expected seeded audit-collab project"); + assert.ok(masterProject, "expected seeded master-agent project"); + + auditProject!.projectUnderstanding = { + projectGoal: "审计兜底目标", + currentProgress: "审计兜底进度", + technicalArchitecture: "审计兜底架构", + currentBlockers: "审计兜底阻塞", + recommendedNextStep: "审计兜底下一步", + sourceTaskId: "task-audit-fallback", + updatedAt: "2026-04-04T17:55:00+08:00", + sourceKind: "thread_sync", + }; + masterProject!.projectUnderstanding = { + projectGoal: "最近活跃目标", + currentProgress: "最近活跃进度", + technicalArchitecture: "最近活跃架构", + currentBlockers: "最近活跃阻塞", + recommendedNextStep: "最近活跃下一步", + sourceTaskId: "task-master-active", + updatedAt: "2026-04-04T18:05:00+08:00", + sourceKind: "thread_sync", + }; + state.threadStatusDocuments = [ + { + documentId: "thread-status-doc-audit-fallback", + projectId: "audit-collab", + threadId: "thread-audit-chief", + threadDisplayName: "审计对话", + folderName: "审计群聊", + deviceId: "mac-studio", + projectGoal: "审计兜底状态", + currentPhase: "审计兜底阶段", + currentProgress: "审计兜底进度", + technicalArchitecture: "审计兜底架构", + currentBlockers: "审计兜底阻塞", + recommendedNextStep: "审计兜底下一步", + keyFiles: ["src/lib/boss-master-agent.ts"], + keyCommands: ["npm run build"], + updatedAt: "2026-04-04T17:56:00+08:00", + sourceTaskId: "task-audit-status", + sourceKind: "incremental_sync", + }, + { + documentId: "thread-status-doc-master-fallback", + projectId: "master-agent", + threadId: "thread-master-main", + threadDisplayName: "主 Agent 汇总", + folderName: "主控线程", + deviceId: "mac-studio", + projectGoal: "最近活跃状态", + currentPhase: "最近活跃阶段", + currentProgress: "最近活跃进度", + technicalArchitecture: "最近活跃架构", + currentBlockers: "最近活跃阻塞", + recommendedNextStep: "最近活跃下一步", + keyFiles: ["src/lib/boss-data.ts"], + keyCommands: ["npm run lint"], + updatedAt: "2026-04-04T18:06:00+08:00", + sourceTaskId: "task-master-status-fallback", + sourceKind: "incremental_sync", + }, + ]; + state.threadProgressEvents = [ + { + eventId: "thread-progress-event-audit-fallback", + projectId: "audit-collab", + threadId: "thread-audit-chief", + threadDisplayName: "审计对话", + deviceId: "mac-studio", + eventType: "progress_updated", + summary: "审计兜底进展摘要", + phase: "审计兜底阶段", + blockerDelta: "审计兜底阻塞", + nextStepDelta: "审计兜底下一步", + createdAt: "2026-04-04T17:56:30+08:00", + sourceTaskId: "task-audit-progress", + }, + { + eventId: "thread-progress-event-master-fallback", + projectId: "master-agent", + threadId: "thread-master-main", + threadDisplayName: "主 Agent 汇总", + deviceId: "mac-studio", + eventType: "progress_updated", + summary: "最近活跃进展摘要", + phase: "最近活跃阶段", + blockerDelta: "最近活跃阻塞", + nextStepDelta: "最近活跃下一步", + createdAt: "2026-04-04T18:06:30+08:00", + sourceTaskId: "task-master-progress-fallback", + }, + ]; + await writeState(state); + + const reply = await replyToMasterAgentUserMessage({ + requestText: "请继续推进线程状态同步(仅深拉兜底)", + requestedBy: "Boss 超级管理员", + requestedByAccount: "17600003315", + mode: "enqueue", + }); + assert.equal(reply.ok, true); + + const queuedTask = (await readState()).masterAgentTasks.find( + (task) => task.projectId === "master-agent" && task.requestText === "请继续推进线程状态同步(仅深拉兜底)", + ); + assert.ok(queuedTask, "expected master-agent task to be queued"); + assert.ok(queuedTask?.executionPrompt.includes("最近活跃状态")); + assert.ok(queuedTask?.executionPrompt.includes("最近活跃进展摘要")); + assert.ok(!queuedTask?.executionPrompt.includes("关键时刻深拉线程兜底:")); +}); + +test("主 Agent 执行 prompt 在没有线程状态文档和进展事件时才会注入深拉兜底", async () => { + await setup(); + + const state = await readState(); + const auditProject = state.projects.find((project) => project.id === "audit-collab"); + assert.ok(auditProject, "expected seeded audit-collab project"); + auditProject!.projectUnderstanding = { + projectGoal: "深拉兜底目标", + currentProgress: "深拉兜底进度", + technicalArchitecture: "深拉兜底架构", + currentBlockers: "深拉兜底阻塞", + recommendedNextStep: "深拉兜底下一步", + sourceTaskId: "task-deep-pull", + updatedAt: "2026-04-04T18:00:00+08:00", + sourceKind: "thread_sync", + }; + state.threadStatusDocuments = []; + state.threadProgressEvents = []; + await writeState(state); + + const reply = await replyToMasterAgentUserMessage({ + requestText: "请继续推进线程状态同步", + requestedBy: "Boss 超级管理员", + requestedByAccount: "17600003315", + mode: "enqueue", + }); + assert.equal(reply.ok, true); + + const queuedTask = (await readState()).masterAgentTasks.find( + (task) => task.projectId === "master-agent" && task.requestText === "请继续推进线程状态同步", + ); + assert.ok(queuedTask, "expected master-agent task to be queued"); assert.ok(queuedTask?.executionPrompt.includes("关键时刻深拉线程兜底:")); assert.ok(queuedTask?.executionPrompt.includes("深拉兜底目标")); - - assert.ok( - queuedTask?.executionPrompt.indexOf("管理员全局主提示词:") < - queuedTask.executionPrompt.indexOf("用户私有主提示词:") && - queuedTask.executionPrompt.indexOf("用户私有主提示词:") < - queuedTask.executionPrompt.indexOf("当前对话附加提示词:") && - queuedTask.executionPrompt.indexOf("当前对话附加提示词:") < - queuedTask.executionPrompt.indexOf("项目记忆:") && - queuedTask.executionPrompt.indexOf("项目记忆:") < - queuedTask.executionPrompt.indexOf("当前消息:") && - queuedTask.executionPrompt.indexOf("当前消息:") < - queuedTask.executionPrompt.indexOf("当前对话覆盖:") && - queuedTask.executionPrompt.indexOf("当前对话覆盖:") < - queuedTask.executionPrompt.indexOf("线程状态文档:") && - queuedTask.executionPrompt.indexOf("线程状态文档:") < - queuedTask.executionPrompt.indexOf("最近进展事件:") && - queuedTask.executionPrompt.indexOf("最近进展事件:") < - queuedTask.executionPrompt.indexOf("关键时刻深拉线程兜底:"), - ); });