feat: add realtime sync and import project understanding
This commit is contained in:
157
android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java
Normal file
157
android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(() -> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user