feat: add realtime sync and import project understanding

This commit is contained in:
kris
2026-04-04 07:53:27 +08:00
parent 9c53e583ba
commit 908ad8858b
12 changed files with 809 additions and 12 deletions

View File

@@ -0,0 +1,157 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.nio.charset.StandardCharsets;
final class BossRealtimeClient {
interface Listener {
void onRealtimeEvent(BossRealtimeEvent event);
}
private final BossApiClient apiClient;
private final Listener listener;
private volatile boolean running;
private @Nullable Thread workerThread;
private @Nullable HttpURLConnection activeConnection;
BossRealtimeClient(BossApiClient apiClient, Listener listener) {
this.apiClient = apiClient;
this.listener = listener;
}
synchronized void start() {
if (running) {
return;
}
running = true;
workerThread = new Thread(this::runLoop, "boss-realtime");
workerThread.start();
}
synchronized void stop() {
running = false;
if (activeConnection != null) {
activeConnection.disconnect();
activeConnection = null;
}
if (workerThread != null) {
workerThread.interrupt();
workerThread = null;
}
}
private void runLoop() {
long backoffMs = 800L;
while (running) {
try {
openAndConsumeStream();
backoffMs = 800L;
} catch (Exception ignored) {
if (!running) {
return;
}
try {
Thread.sleep(backoffMs);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
}
backoffMs = Math.min(backoffMs + 1200L, 5000L);
}
}
}
private void openAndConsumeStream() throws IOException {
HttpURLConnection connection = apiClient.openConnection("/api/v1/events");
activeConnection = connection;
connection.setRequestMethod("GET");
connection.setConnectTimeout(12_000);
connection.setReadTimeout(60_000);
connection.setUseCaches(false);
connection.setDoInput(true);
connection.setRequestProperty("Accept", "text/event-stream");
connection.setRequestProperty("Cache-Control", "no-cache");
connection.setRequestProperty("x-boss-native-app", "1");
String cookie = apiClient.getSessionCookie();
if (!cookie.isEmpty()) {
connection.setRequestProperty("Cookie", cookie);
}
int statusCode = connection.getResponseCode();
if (statusCode < 200 || statusCode >= 300) {
throw new IOException("REALTIME_STREAM_HTTP_" + statusCode);
}
try (InputStream inputStream = connection.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
StringBuilder block = new StringBuilder();
String line;
while (running && (line = reader.readLine()) != null) {
if (line.isEmpty()) {
dispatchEventBlock(block.toString());
block.setLength(0);
continue;
}
block.append(line).append('\n');
}
if (block.length() > 0) {
dispatchEventBlock(block.toString());
}
} finally {
activeConnection = null;
connection.disconnect();
}
}
private void dispatchEventBlock(String rawBlock) {
BossRealtimeEvent event = parseEventBlock(rawBlock);
if (event == null || event.eventName.isEmpty()) {
return;
}
listener.onRealtimeEvent(event);
}
static @Nullable BossRealtimeEvent parseEventBlock(String rawBlock) {
if (rawBlock == null) {
return null;
}
String trimmed = rawBlock.trim();
if (trimmed.isEmpty() || trimmed.startsWith(":")) {
return null;
}
String eventName = "";
StringBuilder dataBuilder = new StringBuilder();
for (String line : rawBlock.split("\n")) {
if (line.startsWith("event:")) {
eventName = line.substring("event:".length()).trim();
} else if (line.startsWith("data:")) {
if (dataBuilder.length() > 0) {
dataBuilder.append('\n');
}
dataBuilder.append(line.substring("data:".length()).trim());
}
}
if (eventName.isEmpty()) {
return null;
}
JSONObject payload = new JSONObject();
if (dataBuilder.length() > 0) {
try {
payload = new JSONObject(dataBuilder.toString());
} catch (JSONException ignored) {
payload = new JSONObject();
}
}
return new BossRealtimeEvent(eventName, payload);
}
}

View File

@@ -0,0 +1,15 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
import org.json.JSONObject;
public final class BossRealtimeEvent {
public final String eventName;
public final JSONObject payload;
public BossRealtimeEvent(String eventName, @Nullable JSONObject payload) {
this.eventName = eventName == null ? "" : eventName.trim();
this.payload = payload == null ? new JSONObject() : payload;
}
}

View File

@@ -24,6 +24,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
private @Nullable JSONObject currentDraft;
private @Nullable JSONObject currentResolution;
private @Nullable JSONObject currentReviewTask;
private @Nullable JSONArray currentUnderstandingTasks;
private @Nullable JSONArray currentProjectUnderstandings;
private final LinkedHashSet<String> selectedCandidateIds = new LinkedHashSet<>();
private final Runnable reviewPollRunnable = this::reload;
@@ -53,7 +55,9 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
runOnUiThread(() -> applyPayload(
response.json.optJSONObject("draft"),
response.json.optJSONObject("resolution"),
response.json.optJSONObject("reviewTask")
response.json.optJSONObject("reviewTask"),
response.json.optJSONArray("understandingTasks"),
response.json.optJSONArray("projectUnderstandings")
));
} catch (Exception error) {
runOnUiThread(() -> {
@@ -64,10 +68,18 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
});
}
private void applyPayload(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
private void applyPayload(
@Nullable JSONObject draft,
@Nullable JSONObject resolution,
@Nullable JSONObject reviewTask,
@Nullable JSONArray understandingTasks,
@Nullable JSONArray projectUnderstandings
) {
currentDraft = draft;
currentResolution = resolution;
currentReviewTask = reviewTask;
currentUnderstandingTasks = understandingTasks;
currentProjectUnderstandings = projectUnderstandings;
selectedCandidateIds.clear();
JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds");
if (selected != null) {
@@ -105,6 +117,8 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
JSONObject draft = currentDraft;
JSONObject resolution = currentResolution;
JSONObject reviewTask = currentReviewTask;
JSONArray understandingTasks = currentUnderstandingTasks;
JSONArray projectUnderstandings = currentProjectUnderstandings;
contentLayout.removeCallbacks(reviewPollRunnable);
replaceContent();
appendContent(BossUi.buildSoftPanel(
@@ -223,6 +237,40 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
));
}
if (understandingTasks != null && understandingTasks.length() > 0) {
int completedCount = 0;
for (int i = 0; i < understandingTasks.length(); i++) {
JSONObject task = understandingTasks.optJSONObject(i);
if (task != null && "completed".equals(task.optString("status", ""))) {
completedCount += 1;
}
}
appendContent(BossUi.buildCard(
this,
"项目理解",
completedCount == understandingTasks.length()
? "主 Agent 已经拿到活跃线程的项目目标、进度和技术架构。"
: "主 Agent 正在向活跃线程追问项目目标、进度和技术架构。",
"已完成 " + completedCount + " / " + understandingTasks.length()
));
}
if (projectUnderstandings != null) {
for (int i = 0; i < projectUnderstandings.length(); i++) {
JSONObject understanding = projectUnderstandings.optJSONObject(i);
if (understanding == null) continue;
appendContent(BossUi.buildCard(
this,
understanding.optString("threadDisplayName", "项目理解"),
"目标:" + understanding.optString("projectGoal", "未提供")
+ "\n进度" + understanding.optString("currentProgress", "未提供")
+ "\n架构" + understanding.optString("technicalArchitecture", "未提供"),
"阻塞:" + understanding.optString("currentBlockers", "")
+ " · 下一步:" + understanding.optString("recommendedNextStep", "继续联调")
));
}
}
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
appendContent(BossUi.buildCard(
@@ -369,14 +417,16 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
reviewResponse.json.optJSONObject("resolution"),
reviewResponse.json.optJSONObject("reviewTask") != null
? reviewResponse.json.optJSONObject("reviewTask")
: reviewResponse.json.optJSONObject("task")
: reviewResponse.json.optJSONObject("task"),
reviewResponse.json.optJSONArray("understandingTasks"),
reviewResponse.json.optJSONArray("projectUnderstandings")
);
});
} catch (Exception error) {
final JSONObject fallbackDraft = selectedDraft;
runOnUiThread(() -> {
if (fallbackDraft != null) {
applyPayload(fallbackDraft, null, null);
applyPayload(fallbackDraft, null, null, null, null);
} else {
setRefreshing(false);
}
@@ -400,7 +450,7 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
runOnUiThread(() -> {
showMessage("已清空当前勾选");
applyPayload(response.json.optJSONObject("draft"), null, null);
applyPayload(response.json.optJSONObject("draft"), null, null, null, null);
});
} catch (Exception error) {
runOnUiThread(() -> {
@@ -425,7 +475,13 @@ public class DeviceImportDraftActivity extends BossScreenActivity {
}
runOnUiThread(() -> {
showMessage("已应用导入");
applyPayload(response.json.optJSONObject("draft"), response.json.optJSONObject("resolution"), null);
applyPayload(
response.json.optJSONObject("draft"),
response.json.optJSONObject("resolution"),
null,
null,
response.json.optJSONArray("projectUnderstandings")
);
});
} catch (Exception error) {
runOnUiThread(() -> {

View File

@@ -45,11 +45,13 @@ public class MainActivity extends AppCompatActivity {
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L;
private static final long REALTIME_REFRESH_THROTTLE_MS = 900L;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private BossApiClient apiClient;
private BossRealtimeClient realtimeClient;
private View loginPanel;
private View contentPanel;
@@ -100,6 +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 Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
private @Nullable RootPagerAdapter rootPagerAdapter;
private boolean syncingRootPagerSelection = false;
@@ -122,6 +125,7 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
apiClient = new BossApiClient(this);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
bindViews();
bindActions();
applyInitialTab(getIntent());
@@ -170,6 +174,7 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
cancelConversationAutoRefresh();
stopRealtimeUpdates();
executor.shutdownNow();
super.onDestroy();
}
@@ -179,12 +184,14 @@ public class MainActivity extends AppCompatActivity {
super.onResume();
conversationAutoRefreshEnabled = true;
updateConversationAutoRefresh();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
conversationAutoRefreshEnabled = false;
cancelConversationAutoRefresh();
stopRealtimeUpdates();
super.onPause();
}
@@ -352,10 +359,43 @@ public class MainActivity extends AppCompatActivity {
});
}
private void refreshCurrentTab() {
void refreshCurrentTab() {
refreshAllData(sessionData);
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || event.eventName.isEmpty()) {
return;
}
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
return;
}
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);
} else if ("devices".equals(activeTab)) {
shouldRefresh =
"devices.updated".equals(event.eventName) ||
"devices.skills.updated".equals(event.eventName) ||
"conversation.updated".equals(event.eventName);
} 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) {
return;
}
lastRealtimeRefreshAt = now;
runOnUiThread(this::refreshCurrentTab);
}
private void refreshAllData(@Nullable JSONObject initialSession) {
startRefreshing(true);
topSubtitle.setText("");
@@ -490,6 +530,7 @@ public class MainActivity extends AppCompatActivity {
loginPanel.setVisibility(View.VISIBLE);
contentPanel.setVisibility(View.GONE);
setLoginLoading(false, hint);
stopRealtimeUpdates();
}
private void showContent() {
@@ -497,6 +538,7 @@ public class MainActivity extends AppCompatActivity {
contentPanel.setVisibility(View.VISIBLE);
setActiveTab(activeTab, false);
updateConversationAutoRefresh();
updateRealtimeSubscription();
}
private void setLoginLoading(boolean loading, String hint) {
@@ -1212,6 +1254,23 @@ public class MainActivity extends AppCompatActivity {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
}
private void updateRealtimeSubscription() {
if (contentPanel != null
&& contentPanel.getVisibility() == View.VISIBLE
&& apiClient != null
&& apiClient.hasSessionHints()) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
private void openMeEntry(String key) {
Intent intent;
switch (key) {

View File

@@ -43,6 +43,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_NAME = "project_name";
private static final long REPLY_WAIT_TIMEOUT_MS = 55_000L;
private static final long REPLY_WAIT_POLL_INTERVAL_MS = 1_500L;
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
private String initialProjectName;
@@ -82,6 +83,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
private ActivityResultLauncher<String> videoPickerLauncher;
private ActivityResultLauncher<String> filePickerLauncher;
private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor();
private @Nullable BossRealtimeClient realtimeClient;
private long lastRealtimeReloadAt;
static final class ChromeBindings {
final boolean multiSelecting;
@@ -205,6 +208,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
new ActivityResultContracts.GetContent(),
uri -> onAttachmentPicked(uri, "file")
);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
BossWindowInsets.applyKeyboardAvoidingInset(composerRow);
BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout);
@@ -242,10 +246,23 @@ public class ProjectDetailActivity extends BossScreenActivity {
@Override
protected void onDestroy() {
stopRealtimeUpdates();
replyWaitExecutor.shutdownNow();
super.onDestroy();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
boolean shouldLoadOnCreate() {
return true;
}
@@ -255,6 +272,32 @@ public class ProjectDetailActivity extends BossScreenActivity {
reload(false);
}
void handleRealtimeEvent(BossRealtimeEvent event) {
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);
}
if (!shouldReload) {
return;
}
long now = System.currentTimeMillis();
if (now - lastRealtimeReloadAt < REALTIME_RELOAD_THROTTLE_MS) {
return;
}
lastRealtimeReloadAt = now;
runOnUiThread(this::triggerRealtimeReload);
}
void triggerRealtimeReload() {
reload();
}
private void reload(boolean forcedScrollToBottom) {
if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId");
@@ -368,6 +411,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints()) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
private void renderQuickActions() {
if (quickActionsLayout == null) {
return;

View File

@@ -0,0 +1,30 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossRealtimeClientTest {
@Test
public void parseEventBlockExtractsEventNameAndJsonPayload() {
BossRealtimeEvent event = BossRealtimeClient.parseEventBlock(
"event: project.messages.updated\n" +
"data: {\"projectId\":\"project-1\",\"status\":\"completed\"}\n\n"
);
assertEquals("project.messages.updated", event.eventName);
assertEquals("project-1", event.payload.optString("projectId"));
assertEquals("completed", event.payload.optString("status"));
}
@Test
public void parseEventBlockReturnsNullForKeepaliveComment() {
assertNull(BossRealtimeClient.parseEventBlock(": keepalive\n\n"));
}
}

View File

@@ -37,7 +37,9 @@ public class DeviceImportDraftActivityTest {
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null)
);
View content = activity.findViewById(R.id.screen_content);
@@ -64,7 +66,9 @@ public class DeviceImportDraftActivityTest {
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAppliedResolution()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null)
);
View content = activity.findViewById(R.id.screen_content);
@@ -92,13 +96,18 @@ public class DeviceImportDraftActivityTest {
"applyPayload",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildPendingResolutionDraft()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask())
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildQueuedReviewTask()),
ReflectionHelpers.ClassParameter.from(JSONArray.class, buildUnderstandingTasks()),
ReflectionHelpers.ClassParameter.from(JSONArray.class, buildProjectUnderstandings())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "主 Agent 审核中"));
assertTrue(viewTreeContainsText(content, "审核任务"));
assertTrue(viewTreeContainsText(content, "状态queued"));
assertTrue(viewTreeContainsText(content, "项目理解"));
assertTrue(viewTreeContainsText(content, "北区试产线回归"));
assertTrue(viewTreeContainsText(content, "树莓派二代接入与联调"));
}
private static JSONObject buildPendingDraft() throws Exception {
@@ -200,6 +209,27 @@ public class DeviceImportDraftActivityTest {
.put("status", "queued");
}
private static JSONArray buildUnderstandingTasks() throws Exception {
return new JSONArray()
.put(new JSONObject()
.put("taskId", "mastertask-understanding-1")
.put("candidateId", "candidate-1")
.put("threadDisplayName", "北区试产线回归")
.put("status", "completed"));
}
private static JSONArray buildProjectUnderstandings() throws Exception {
return new JSONArray()
.put(new JSONObject()
.put("candidateId", "candidate-1")
.put("threadDisplayName", "北区试产线回归")
.put("projectGoal", "完成树莓派二代接入与联调")
.put("currentProgress", "正在核对接线和控制链路")
.put("technicalArchitecture", "Next.js 控制台 + local-agent + Codex 线程")
.put("currentBlockers", "串口稳定性待验证")
.put("recommendedNextStep", "先确认串口日志"));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();

View File

@@ -0,0 +1,56 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityRealtimeTest {
@Test
public void conversationRealtimeEventRefreshesVisibleConversationTab() 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"))
)
);
assertEquals(1, activity.refreshCount);
}
@Test
public void devicesRealtimeEventDoesNotRefreshConversationTab() 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("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
)
);
assertEquals(0, activity.refreshCount);
}
public static class TestMainActivity extends MainActivity {
int refreshCount;
@Override
void refreshCurrentTab() {
refreshCount += 1;
}
}
}

View File

@@ -0,0 +1,77 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.Intent;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityRealtimeTest {
@Test
public void matchingProjectMessageEventTriggersReload() 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"))
)
);
assertEquals(1, activity.reloadCount);
}
@Test
public void unrelatedProjectMessageEventDoesNotTriggerReload() 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-2"))
)
);
assertEquals(0, activity.reloadCount);
}
public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
int reloadCount;
@Override
boolean shouldLoadOnCreate() {
return false;
}
@Override
protected void reload() {
reloadCount += 1;
}
}
}

View File

@@ -482,6 +482,19 @@ export interface DeviceImportResolution {
appliedBy?: string;
}
export interface DeviceImportProjectUnderstanding {
candidateId: string;
threadDisplayName: string;
folderName: string;
projectGoal: string;
currentProgress: string;
technicalArchitecture: string;
currentBlockers: string;
recommendedNextStep: string;
sourceTaskId: string;
updatedAt: string;
}
export interface VerificationCode {
id: string;
account: string;
@@ -646,6 +659,8 @@ export interface MasterAgentTask {
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
deviceImportDraftId?: string;
deviceImportCandidateId?: string;
deviceImportCandidateFolderName?: string;
status: MasterAgentTaskStatus;
requestedAt: string;
claimedAt?: string;
@@ -2926,6 +2941,8 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
: undefined,
orchestrationBackendLabel: task.orchestrationBackendLabel,
deviceImportDraftId: task.deviceImportDraftId,
deviceImportCandidateId: task.deviceImportCandidateId,
deviceImportCandidateFolderName: task.deviceImportCandidateFolderName,
status: task.status ?? "queued",
requestedAt: task.requestedAt ?? nowIso(),
claimedAt: task.claimedAt,
@@ -5130,6 +5147,8 @@ export async function queueMasterAgentTask(payload: {
targetCodexFolderRef?: string;
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
deviceImportCandidateId?: string;
deviceImportCandidateFolderName?: string;
}) {
const task = await mutateState((state) => {
const task: MasterAgentTask = {
@@ -5159,6 +5178,8 @@ export async function queueMasterAgentTask(payload: {
targetCodexFolderRef: payload.targetCodexFolderRef,
orchestrationBackendId: payload.orchestrationBackendId,
orchestrationBackendLabel: payload.orchestrationBackendLabel,
deviceImportCandidateId: payload.deviceImportCandidateId,
deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName,
status: "queued",
requestedAt: nowIso(),
};
@@ -6114,11 +6135,20 @@ export async function completeMasterAgentTask(payload: {
masterSummary: payload.replyBody?.trim(),
});
} else if (!attachmentProjectId && payload.status === "completed" && task.replyBody) {
const isDeviceImportUnderstanding =
task.taskType === "conversation_reply" &&
task.projectId === "master-agent" &&
Boolean(task.deviceImportDraftId && task.deviceImportCandidateId);
const isThreadConversationReply =
task.taskType === "conversation_reply" &&
task.projectId !== "master-agent" &&
Boolean(task.targetProjectId && task.targetThreadId);
if (isThreadConversationReply) {
if (isDeviceImportUnderstanding) {
const draft = state.deviceImportDrafts.find((item) => item.draftId === task.deviceImportDraftId);
if (draft) {
publishBossEvent("devices.updated", { deviceId: draft.deviceId });
}
} else if (isThreadConversationReply) {
const threadProject = state.projects.find(
(item) => item.id === (task.targetProjectId ?? task.projectId),
);
@@ -7009,7 +7039,110 @@ export async function getLatestDeviceImportDraft(deviceId: string) {
item.deviceImportDraftId === draft.draftId,
) ?? null
: null;
return { draft, resolution, reviewTask };
const understandingTasks = draft
? listDeviceImportUnderstandingTasks(state, draft.draftId)
: [];
const projectUnderstandings = draft
? deriveDeviceImportProjectUnderstandings(state, draft.draftId)
: [];
return { draft, resolution, reviewTask, understandingTasks, projectUnderstandings };
}
function listDeviceImportUnderstandingTasks(state: BossState, draftId: string) {
const latestByCandidate = new Map<string, MasterAgentTask>();
for (const task of state.masterAgentTasks) {
if (
task.taskType !== "conversation_reply" ||
task.deviceImportDraftId !== draftId ||
!task.deviceImportCandidateId
) {
continue;
}
const existing = latestByCandidate.get(task.deviceImportCandidateId);
if (!existing || existing.requestedAt < task.requestedAt) {
latestByCandidate.set(task.deviceImportCandidateId, task);
}
}
return [...latestByCandidate.values()].map((task) => ({
taskId: task.taskId,
candidateId: task.deviceImportCandidateId ?? "",
threadDisplayName: task.targetThreadDisplayName ?? "",
folderName: task.deviceImportCandidateFolderName ?? "",
status: task.status,
updatedAt: task.completedAt ?? task.claimedAt ?? task.requestedAt,
}));
}
function parseDeviceImportUnderstandingReply(
task: Pick<MasterAgentTask, "replyBody" | "deviceImportCandidateId" | "targetThreadDisplayName" | "deviceImportCandidateFolderName" | "taskId" | "completedAt" | "requestedAt">,
): DeviceImportProjectUnderstanding | null {
const candidateId = task.deviceImportCandidateId?.trim();
const replyBody = task.replyBody?.trim();
if (!candidateId || !replyBody) {
return null;
}
const fencedMatch = replyBody.match(/```(?:json)?\s*([\s\S]*?)```/i);
const jsonCandidate = fencedMatch?.[1]?.trim() ?? replyBody;
let parsed:
| {
projectGoal?: string;
currentProgress?: string;
technicalArchitecture?: string;
currentBlockers?: string;
recommendedNextStep?: string;
}
| null = null;
try {
parsed = JSON.parse(jsonCandidate);
} catch {
return null;
}
const projectGoal = parsed?.projectGoal?.trim() ?? "";
const currentProgress = parsed?.currentProgress?.trim() ?? "";
const technicalArchitecture = parsed?.technicalArchitecture?.trim() ?? "";
const currentBlockers = parsed?.currentBlockers?.trim() ?? "";
const recommendedNextStep = parsed?.recommendedNextStep?.trim() ?? "";
if (!projectGoal && !currentProgress && !technicalArchitecture && !currentBlockers && !recommendedNextStep) {
return null;
}
return {
candidateId,
threadDisplayName: task.targetThreadDisplayName?.trim() || "未命名线程",
folderName: task.deviceImportCandidateFolderName?.trim() || "",
projectGoal,
currentProgress,
technicalArchitecture,
currentBlockers,
recommendedNextStep,
sourceTaskId: task.taskId,
updatedAt: task.completedAt ?? task.requestedAt,
};
}
function deriveDeviceImportProjectUnderstandings(state: BossState, draftId: string) {
const latestByCandidate = new Map<string, DeviceImportProjectUnderstanding>();
for (const task of state.masterAgentTasks) {
if (
task.taskType !== "conversation_reply" ||
task.deviceImportDraftId !== draftId ||
task.status !== "completed" ||
!task.deviceImportCandidateId
) {
continue;
}
const understanding = parseDeviceImportUnderstandingReply(task);
if (!understanding) {
continue;
}
const existing = latestByCandidate.get(understanding.candidateId);
if (!existing || existing.updatedAt < understanding.updatedAt) {
latestByCandidate.set(understanding.candidateId, understanding);
}
}
return [...latestByCandidate.values()];
}
export async function previewDeviceImportResolution(input: { deviceId: string }) {

View File

@@ -1579,6 +1579,35 @@ function buildDeviceImportResolutionPrompt(params: {
].join("\n");
}
function buildDeviceImportUnderstandingPrompt(params: {
deviceName: string;
draftId: string;
candidateId: string;
threadDisplayName: string;
folderName: string;
lastActiveAt: string;
}) {
return [
"你正在协助 Boss 控制台完成新设备导入前的项目理解。",
"请以当前活跃线程的视角,直接总结这个项目当前最关键的信息。",
"输出必须是 JSON对象结构如下",
'{ "projectGoal": "一句中文目标", "currentProgress": "一句中文进度", "technicalArchitecture": "一句中文架构说明", "currentBlockers": "一句中文阻塞说明", "recommendedNextStep": "一句中文建议动作" }',
"要求:",
"1. 只输出 JSON不要输出额外解释。",
"2. 用中文,句子短而明确。",
"3. 如果某项信息不确定,也要明确写出当前已知范围,不要留空。",
"",
`deviceName: ${params.deviceName}`,
`draftId: ${params.draftId}`,
`candidateId: ${params.candidateId}`,
`threadDisplayName: ${params.threadDisplayName}`,
`folderName: ${params.folderName}`,
`lastActiveAt: ${params.lastActiveAt}`,
"",
"请回答当前项目:目标、进度、技术架构、阻塞点、下一步建议。",
].join("\n");
}
export async function queueDeviceImportResolutionTask(params: {
deviceId: string;
reviewedBy: string;
@@ -1599,6 +1628,35 @@ export async function queueDeviceImportResolutionTask(params: {
const selectedCandidates = draft.candidates.filter((candidate) =>
draft.selectedCandidateIds.includes(candidate.candidateId),
);
const understandingTasks = await Promise.all(
selectedCandidates
.filter((candidate) => candidate.codexThreadRef?.trim())
.map((candidate) =>
queueMasterAgentTask({
projectId: "master-agent",
taskType: "conversation_reply",
requestMessageId: draft.draftId,
requestText: `请总结线程 ${candidate.threadDisplayName} 当前项目目标与进度`,
executionPrompt: buildDeviceImportUnderstandingPrompt({
deviceName: device.name,
draftId: draft.draftId,
candidateId: candidate.candidateId,
threadDisplayName: candidate.threadDisplayName,
folderName: candidate.folderName,
lastActiveAt: candidate.lastActiveAt,
}),
requestedBy: params.reviewedBy,
requestedByAccount: params.reviewedBy,
deviceId: device.id,
deviceImportDraftId: draft.draftId,
deviceImportCandidateId: candidate.candidateId,
deviceImportCandidateFolderName: candidate.folderName,
targetThreadDisplayName: candidate.threadDisplayName,
targetCodexThreadRef: candidate.codexThreadRef,
targetCodexFolderRef: candidate.codexFolderRef ?? candidate.folderRef,
}),
),
);
const task = await queueMasterAgentTask({
projectId: "master-agent",
taskType: "device_import_resolution",
@@ -1639,6 +1697,13 @@ export async function queueDeviceImportResolutionTask(params: {
deviceImportDraftId: task.deviceImportDraftId,
},
draft: latest.draft ?? undefined,
understandingTasks: understandingTasks.map((item) => ({
taskId: item.taskId,
taskType: item.taskType,
status: item.status,
candidateId: item.deviceImportCandidateId,
threadDisplayName: item.targetThreadDisplayName,
})),
...(latest.resolution ? { resolution: latest.resolution } : {}),
};
}

View File

@@ -184,11 +184,14 @@ test("device import draft review queues a master-agent task, then completion wri
const reviewPayload = (await reviewResponse.json()) as {
draft?: { status: string; selectedCandidateIds: string[] };
resolution?: { summary: string; items: Array<{ action: string; threadDisplayName: string }> };
understandingTasks?: Array<{ taskId: string; status: "queued" | "running" | "completed" | "failed" }>;
task: { taskId: string; status: "queued" | "running" | "completed" | "failed" };
};
assert.equal(reviewPayload.task.status, "queued");
assert.equal(reviewPayload.draft?.status, "pending_resolution");
assert.equal(reviewPayload.resolution, undefined);
assert.equal(reviewPayload.understandingTasks?.length, 1);
assert.equal(reviewPayload.understandingTasks?.[0]?.status, "queued");
const reviewedState = await readState();
const resolutionTask = reviewedState.masterAgentTasks.find(
@@ -198,6 +201,14 @@ test("device import draft review queues a master-agent task, then completion wri
task.status === "queued",
);
assert.ok(resolutionTask, "expected import review to leave a queued master-agent task trace");
const understandingTask = reviewedState.masterAgentTasks.find(
(task) =>
task.taskType === "conversation_reply" &&
task.deviceImportDraftId === draftPayload.draft?.draftId &&
task.status === "queued",
);
assert.ok(understandingTask, "expected import review to leave a queued thread understanding task trace");
assert.equal(understandingTask?.targetThreadDisplayName, "北区试产线回归");
const completionResponse = await completeMasterTaskRoute(
await createAuthedRequest(
@@ -226,6 +237,30 @@ test("device import draft review queues a master-agent task, then completion wri
);
assert.equal(completionResponse.status, 200);
const understandingCompletionResponse = await completeMasterTaskRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${understandingTask?.taskId}/complete`,
"POST",
{
deviceId: enrollmentPayload.device.id,
status: "completed",
replyBody: JSON.stringify(
{
projectGoal: "完成北区试产线树莓派二代接入与联调。",
currentProgress: "已经完成线程导入准备,当前在核对设备接线和控制链路。",
technicalArchitecture: "前台是 Next.js 控制台,设备端通过 local-agent 与 Codex 线程联动。",
currentBlockers: "还缺少树莓派二代的串口稳定性验证。",
recommendedNextStep: "先确认接线和串口日志,再继续设备控制指令联调。",
},
null,
2,
),
},
),
{ params: Promise.resolve({ taskId: understandingTask?.taskId ?? "" }) },
);
assert.equal(understandingCompletionResponse.status, 200);
const completedState = await readState();
const completedDraft = completedState.deviceImportDrafts.find(
(draft) => draft.deviceId === enrollmentPayload.device.id,
@@ -237,6 +272,33 @@ test("device import draft review queues a master-agent task, then completion wri
assert.equal(completedResolution?.status, "ready");
assert.match(completedResolution?.summary ?? "", /MacBook Pro 导入建议/);
const refreshedDraftResponse = await getImportDraftRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`,
"GET",
),
{ params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) },
);
assert.equal(refreshedDraftResponse.status, 200);
const refreshedDraftPayload = (await refreshedDraftResponse.json()) as {
projectUnderstandings?: Array<{
threadDisplayName: string;
projectGoal: string;
currentProgress: string;
technicalArchitecture: string;
currentBlockers: string;
recommendedNextStep: string;
}>;
understandingTasks?: Array<{ taskId: string; status: string }>;
};
assert.equal(refreshedDraftPayload.projectUnderstandings?.length, 1);
assert.equal(refreshedDraftPayload.projectUnderstandings?.[0]?.threadDisplayName, "北区试产线回归");
assert.match(
refreshedDraftPayload.projectUnderstandings?.[0]?.projectGoal ?? "",
/树莓派二代接入与联调/,
);
assert.equal(refreshedDraftPayload.understandingTasks?.[0]?.status, "completed");
const applyResponse = await applyImportDraftRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/apply`,