feat: queue master-agent chat replies
This commit is contained in:
@@ -97,6 +97,21 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateProjectAgentControls(
|
||||
String projectId,
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride
|
||||
) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
|
||||
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
|
||||
}
|
||||
|
||||
public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put(
|
||||
|
||||
@@ -35,6 +35,8 @@ import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class ProjectDetailActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
@@ -46,6 +48,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private String initialProjectName;
|
||||
private boolean projectIsGroup;
|
||||
private String projectFolderName;
|
||||
private @Nullable String currentAgentModelOverride;
|
||||
private @Nullable String currentReasoningEffortOverride;
|
||||
private LinearLayout quickActionsLayout;
|
||||
private LinearLayout composerRow;
|
||||
private LinearLayout multiSelectActionsLayout;
|
||||
@@ -59,6 +63,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private boolean renderNearBottom;
|
||||
private boolean renderForcedScrollToBottom;
|
||||
private boolean conversationInfoReady;
|
||||
private boolean masterAgentReplyWaiting;
|
||||
private @Nullable String masterAgentReplyBaselineMessageId;
|
||||
private String currentScreenTitle;
|
||||
private String currentScreenSubtitle;
|
||||
private String projectCollaborationMode = "development";
|
||||
@@ -70,6 +76,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private ActivityResultLauncher<String> imagePickerLauncher;
|
||||
private ActivityResultLauncher<String> videoPickerLauncher;
|
||||
private ActivityResultLauncher<String> filePickerLauncher;
|
||||
private final ExecutorService replyWaitExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
static final class ChromeBindings {
|
||||
final boolean multiSelecting;
|
||||
@@ -212,6 +219,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
replyWaitExecutor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
boolean shouldLoadOnCreate() {
|
||||
return true;
|
||||
}
|
||||
@@ -239,7 +252,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
setRefreshing(false);
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
if (pendingOutgoingBubble == null) {
|
||||
if (pendingOutgoingBubble == null && !masterAgentReplyWaiting) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "项目详情加载失败:" + error.getMessage()));
|
||||
} else {
|
||||
showMessage("项目详情刷新失败:" + error.getMessage());
|
||||
@@ -277,6 +290,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", "development");
|
||||
projectApprovalState = project == null ? "not_required" : project.optString("approvalState", "not_required");
|
||||
JSONObject agentControls = project == null ? null : project.optJSONObject("agentControls");
|
||||
currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null));
|
||||
currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", null));
|
||||
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
|
||||
conversationInfoReady = project != null;
|
||||
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
|
||||
@@ -305,6 +321,17 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
appendContent(BossUi.buildMessagePlaceholder(this, "还没有项目消息,先发一条开始对话。"));
|
||||
}
|
||||
|
||||
boolean masterAgentStillWaiting = isMasterAgentConversation()
|
||||
&& masterAgentReplyWaiting
|
||||
&& !ProjectChatUiState.hasReplyBeyondBaseline(project, masterAgentReplyBaselineMessageId);
|
||||
if (isMasterAgentConversation() && masterAgentReplyWaiting && !masterAgentStillWaiting) {
|
||||
masterAgentReplyWaiting = false;
|
||||
masterAgentReplyBaselineMessageId = null;
|
||||
}
|
||||
if (masterAgentStillWaiting) {
|
||||
appendContent(buildMasterAgentThinkingPlaceholder());
|
||||
}
|
||||
|
||||
setRefreshing(false);
|
||||
updateSelectionUi();
|
||||
if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, renderForcedScrollToBottom)) {
|
||||
@@ -491,7 +518,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return;
|
||||
}
|
||||
if (waitSpec.shouldWait) {
|
||||
startReplyWait(waitSpec, false, "消息已发送,正在等待回复…");
|
||||
if (isMasterAgentConversation()) {
|
||||
startMasterAgentReplyWait(waitSpec, false, "消息已发送,主 Agent 思考中");
|
||||
} else {
|
||||
startReplyWait(waitSpec, false, "消息已发送,正在等待回复…");
|
||||
}
|
||||
return;
|
||||
}
|
||||
composerSending = false;
|
||||
@@ -586,6 +617,162 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
conversationInfoLauncher.launch(intent);
|
||||
}
|
||||
|
||||
private void showMasterAgentMoreMenu() {
|
||||
if (!isMasterAgentConversation()) {
|
||||
return;
|
||||
}
|
||||
new AlertDialog.Builder(this)
|
||||
.setItems(new CharSequence[]{"模型", "推理强度", "会话信息", "刷新"}, (dialog, which) -> {
|
||||
switch (which) {
|
||||
case 0:
|
||||
showMasterAgentModelPicker();
|
||||
break;
|
||||
case 1:
|
||||
showMasterAgentReasoningPicker();
|
||||
break;
|
||||
case 2:
|
||||
openConversationInfo();
|
||||
break;
|
||||
case 3:
|
||||
reload(true);
|
||||
break;
|
||||
default:
|
||||
dialog.dismiss();
|
||||
break;
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showMasterAgentModelPicker() {
|
||||
if (!isMasterAgentConversation()) {
|
||||
return;
|
||||
}
|
||||
final String[] options = buildMasterAgentModelOptions();
|
||||
int checkedIndex = findCheckedIndex(options, currentAgentModelOverride);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("模型")
|
||||
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
|
||||
if (which == 0) {
|
||||
dialog.dismiss();
|
||||
updateMasterAgentControls(null, currentReasoningEffortOverride, "模型已恢复默认");
|
||||
return;
|
||||
}
|
||||
if (which == options.length - 1) {
|
||||
dialog.dismiss();
|
||||
showCustomMasterAgentModelDialog();
|
||||
return;
|
||||
}
|
||||
dialog.dismiss();
|
||||
updateMasterAgentControls(options[which], currentReasoningEffortOverride, "模型已更新为 " + options[which]);
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showCustomMasterAgentModelDialog() {
|
||||
final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
|
||||
input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4" : currentAgentModelOverride);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("自定义模型")
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) ->
|
||||
updateMasterAgentControls(
|
||||
normalizeControlValue(input.getText() == null ? null : input.getText().toString()),
|
||||
currentReasoningEffortOverride,
|
||||
"模型已更新"
|
||||
))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showMasterAgentReasoningPicker() {
|
||||
if (!isMasterAgentConversation()) {
|
||||
return;
|
||||
}
|
||||
final String[] options = new String[]{"沿用默认", "low", "medium", "high"};
|
||||
int checkedIndex = findCheckedIndex(options, currentReasoningEffortOverride);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("推理强度")
|
||||
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
String reasoningOverride = which == 0 ? null : options[which];
|
||||
updateMasterAgentControls(
|
||||
currentAgentModelOverride,
|
||||
reasoningOverride,
|
||||
which == 0 ? "推理强度已恢复默认" : "推理强度已更新为 " + options[which]
|
||||
);
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void updateMasterAgentControls(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
String successMessage
|
||||
) {
|
||||
if (!isMasterAgentConversation() || projectId == null || projectId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls(
|
||||
projectId,
|
||||
modelOverride,
|
||||
reasoningEffortOverride
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
JSONObject controls = response.json.optJSONObject("controls");
|
||||
runOnUiThread(() -> {
|
||||
currentAgentModelOverride = normalizeControlValue(controls == null ? null : controls.optString("modelOverride", null));
|
||||
currentReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("reasoningEffortOverride", null));
|
||||
showMessage(successMessage);
|
||||
reload(true);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String[] buildMasterAgentModelOptions() {
|
||||
List<String> options = new ArrayList<>();
|
||||
options.add("沿用默认");
|
||||
if (!TextUtils.isEmpty(currentAgentModelOverride)) {
|
||||
options.add(currentAgentModelOverride);
|
||||
}
|
||||
if (!options.contains("gpt-5.4")) {
|
||||
options.add("gpt-5.4");
|
||||
}
|
||||
if (!options.contains("gpt-5.1")) {
|
||||
options.add("gpt-5.1");
|
||||
}
|
||||
if (!options.contains("gpt-4.1")) {
|
||||
options.add("gpt-4.1");
|
||||
}
|
||||
options.add("自定义...");
|
||||
return options.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private int findCheckedIndex(String[] options, @Nullable String selectedValue) {
|
||||
if (TextUtils.isEmpty(selectedValue)) {
|
||||
return 0;
|
||||
}
|
||||
for (int i = 0; i < options.length; i++) {
|
||||
if (selectedValue.equals(options[i])) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private View buildPendingDispatchPlanView(JSONObject dispatchPlan) {
|
||||
LinearLayout container = new LinearLayout(this);
|
||||
container.setOrientation(LinearLayout.VERTICAL);
|
||||
@@ -1154,10 +1341,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
finish();
|
||||
});
|
||||
refreshButton.setVisibility(bindings.showRefresh ? View.VISIBLE : View.GONE);
|
||||
refreshButton.setVisibility(bindings.showRefresh && !isMasterAgentConversation() ? View.VISIBLE : View.GONE);
|
||||
titleView.setText(bindings.title);
|
||||
subtitleView.setText(bindings.subtitle);
|
||||
if (bindings.showHeaderAction) {
|
||||
if (bindings.multiSelecting) {
|
||||
hideHeaderAction();
|
||||
} else if (isMasterAgentConversation()) {
|
||||
setHeaderAction("...", v -> showMasterAgentMoreMenu());
|
||||
} else if (bindings.showHeaderAction) {
|
||||
setHeaderAction(WechatSurfaceMapper.conversationInfoActionLabel(), v -> openConversationInfo());
|
||||
} else {
|
||||
hideHeaderAction();
|
||||
@@ -1214,6 +1405,22 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return folderName + " · 设备:" + deviceLabel;
|
||||
}
|
||||
|
||||
private boolean isMasterAgentConversation() {
|
||||
return "master-agent".equals(projectId);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String normalizeControlValue(@Nullable String value) {
|
||||
if (TextUtils.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private View buildMasterAgentThinkingPlaceholder() {
|
||||
return BossUi.buildHintPill(this, "主 Agent 思考中");
|
||||
}
|
||||
|
||||
private void scrollChatToBottom() {
|
||||
if (chatScrollView == null) {
|
||||
return;
|
||||
@@ -1597,6 +1804,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
boolean includeDispatchPlans,
|
||||
String waitingMessage
|
||||
) {
|
||||
if (isMasterAgentConversation()) {
|
||||
startMasterAgentReplyWait(waitSpec, includeDispatchPlans, waitingMessage);
|
||||
return;
|
||||
}
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(true);
|
||||
@@ -1604,6 +1815,21 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
executor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
|
||||
}
|
||||
|
||||
private void startMasterAgentReplyWait(
|
||||
ProjectChatUiState.ReplyWaitSpec waitSpec,
|
||||
boolean includeDispatchPlans,
|
||||
String waitingMessage
|
||||
) {
|
||||
masterAgentReplyWaiting = true;
|
||||
masterAgentReplyBaselineMessageId = waitSpec.baselineMessageId;
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(false);
|
||||
showMessage(waitingMessage);
|
||||
reload(true);
|
||||
replyWaitExecutor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
|
||||
}
|
||||
|
||||
private void pollUntilReply(
|
||||
ProjectChatUiState.ReplyWaitSpec waitSpec,
|
||||
boolean includeDispatchPlans
|
||||
@@ -1619,7 +1845,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (!renderedInitialSnapshot || hasReply) {
|
||||
runOnUiThread(() -> {
|
||||
renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload);
|
||||
if (!hasReply) {
|
||||
if (!hasReply && !isMasterAgentConversation()) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(true);
|
||||
@@ -1630,6 +1856,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
if (hasReply) {
|
||||
runOnUiThread(() -> {
|
||||
if (isMasterAgentConversation()) {
|
||||
masterAgentReplyWaiting = false;
|
||||
masterAgentReplyBaselineMessageId = null;
|
||||
}
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(false);
|
||||
@@ -1642,6 +1872,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
runOnUiThread(() -> {
|
||||
if (isMasterAgentConversation()) {
|
||||
masterAgentReplyWaiting = false;
|
||||
masterAgentReplyBaselineMessageId = null;
|
||||
}
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(false);
|
||||
@@ -1650,6 +1884,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
if (isMasterAgentConversation()) {
|
||||
masterAgentReplyWaiting = false;
|
||||
masterAgentReplyBaselineMessageId = null;
|
||||
}
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(false);
|
||||
|
||||
@@ -69,6 +69,31 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectAgentControlsUsesScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectAgentControls("master-agent");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateProjectAgentControlsWritesModelAndReasoningOverrides() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls("master-agent", "gpt-5.4", "high");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{\"modelOverride\":\"gpt-5.4\",\"reasoningEffortOverride\":\"high\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sendProjectMessageUsesExtendedReadTimeoutForMasterAgent() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages"));
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
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.shadows.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ProjectDetailActivityMasterAgentMenuTest {
|
||||
@Test
|
||||
public void masterAgentMoreMenuShowsWechatActions() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentMoreMenu");
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
|
||||
assertMenuItem(listView, 0, "模型");
|
||||
assertMenuItem(listView, 1, "推理强度");
|
||||
assertMenuItem(listView, 2, "会话信息");
|
||||
assertMenuItem(listView, 3, "刷新");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentWaitingStateRendersThinkingPlaceholder() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", true);
|
||||
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
|
||||
|
||||
JSONObject project = new JSONObject()
|
||||
.put("id", "master-agent")
|
||||
.put("name", "主 Agent")
|
||||
.put("messages", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "msg-user-1")
|
||||
.put("sender", "user")
|
||||
.put("senderLabel", "Boss 超级管理员")
|
||||
.put("body", "帮我检查当前主控")
|
||||
.put("kind", "text")));
|
||||
JSONObject payload = new JSONObject().put("project", project);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
|
||||
View contentRoot = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(contentRoot);
|
||||
assertTrue(viewTreeContainsText(contentRoot, "主 Agent 思考中"));
|
||||
}
|
||||
|
||||
private static void assertMenuItem(ListView listView, int index, String expectedText) {
|
||||
View item = listView.getAdapter().getView(index, null, listView);
|
||||
assertTrue(viewTreeContainsText(item, expectedText));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,27 @@ public class ProjectDetailActivityUiTest {
|
||||
assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentHeaderUsesWechatMoreMenuLabel() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
|
||||
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "updateSelectionUi");
|
||||
|
||||
Button headerAction = activity.findViewById(R.id.screen_header_action);
|
||||
assertEquals(View.VISIBLE, headerAction.getVisibility());
|
||||
assertEquals("...", headerAction.getText().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void outgoingAttachmentMetaPrefersTimeOnly() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
|
||||
@@ -103,15 +103,26 @@ export async function POST(
|
||||
accountId?: string;
|
||||
requestId?: string;
|
||||
taskId?: string;
|
||||
masterReplyState?: "queued" | "running" | "completed";
|
||||
task?: {
|
||||
taskId: string;
|
||||
taskType: "conversation_reply";
|
||||
status: "queued" | "running" | "completed";
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
let task:
|
||||
| {
|
||||
taskId: string;
|
||||
taskType: "conversation_reply";
|
||||
status: "queued" | "completed";
|
||||
status: "queued" | "running" | "completed";
|
||||
}
|
||||
| null = null;
|
||||
let masterReplyState:
|
||||
| "queued"
|
||||
| "running"
|
||||
| "completed"
|
||||
| null = null;
|
||||
|
||||
if (shouldCreateDispatchPlan) {
|
||||
try {
|
||||
@@ -173,13 +184,13 @@ export async function POST(
|
||||
requestedBy: session.displayName,
|
||||
requestedByAccount: session.account,
|
||||
currentSessionExpiresAt: session.expiresAt,
|
||||
mode: "enqueue",
|
||||
});
|
||||
if (masterReply?.ok && masterReply.taskId) {
|
||||
task = {
|
||||
taskId: masterReply.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: masterReply.requestId ? "completed" : "queued",
|
||||
};
|
||||
task = masterReply.task ?? null;
|
||||
masterReplyState = masterReply.masterReplyState ?? null;
|
||||
} else {
|
||||
masterReplyState = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +203,7 @@ export async function POST(
|
||||
message,
|
||||
masterReply,
|
||||
task,
|
||||
masterReplyState,
|
||||
dispatchPlan,
|
||||
dispatchRecommendation,
|
||||
collaborationGate,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
completeMasterAgentTask,
|
||||
getProjectAttachment,
|
||||
getAttachmentStorageConfig,
|
||||
getProjectAgentControls,
|
||||
getLatestDeviceImportDraft,
|
||||
getRuntimeAiAccountById,
|
||||
getMasterAgentRuntimeAccount,
|
||||
@@ -18,11 +19,38 @@ import {
|
||||
updateAttachmentAnalysisResult,
|
||||
updateAiAccountHealth,
|
||||
} from "@/lib/boss-data";
|
||||
import type { DispatchPlanTarget, Project } from "@/lib/boss-data";
|
||||
import type { DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort } from "@/lib/boss-data";
|
||||
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
|
||||
import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss";
|
||||
import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file";
|
||||
|
||||
type MasterAgentReplyState = "queued" | "running" | "completed";
|
||||
const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai";
|
||||
|
||||
type QueuedMasterAgentReplyEnvelope = {
|
||||
ok: true;
|
||||
accountId: string;
|
||||
taskId: string;
|
||||
masterReplyState: MasterAgentReplyState;
|
||||
task: {
|
||||
taskId: string;
|
||||
taskType: "conversation_reply";
|
||||
status: MasterAgentReplyState;
|
||||
};
|
||||
};
|
||||
|
||||
function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
|
||||
if (!agentControls) {
|
||||
return "当前对话覆盖:无";
|
||||
}
|
||||
|
||||
return [
|
||||
"当前对话覆盖:",
|
||||
`model=${agentControls.modelOverride ?? "默认"}`,
|
||||
`reasoning=${agentControls.reasoningEffortOverride ?? "默认"}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function buildMasterAgentInstructions() {
|
||||
return [
|
||||
"你是 Boss 控制台的主 Agent。",
|
||||
@@ -53,6 +81,7 @@ function buildRuntimeDigest(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
agentControls?: ProjectAgentControls | null,
|
||||
) {
|
||||
const recentMessages = state.projects
|
||||
.find((project) => project.id === "master-agent")
|
||||
@@ -91,6 +120,7 @@ function buildRuntimeDigest(
|
||||
`登录会话策略:成功登录后默认保持 ${Math.round(AUTH_SESSION_TTL_MS / 24 / 60 / 60_000)} 天。`,
|
||||
"Cookie Max-Age:2592000 秒。",
|
||||
currentSessionExpiresAt ? `当前请求会话到期时间:${currentSessionExpiresAt}` : undefined,
|
||||
buildAgentControlsDigest(agentControls),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
@@ -210,6 +240,7 @@ async function replyViaOpenAiAccount(params: {
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
senderLabel: string;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
}) {
|
||||
if (!params.account?.apiKey?.trim()) {
|
||||
throw new Error("OPENAI_ACCOUNT_NOT_CONFIGURED");
|
||||
@@ -217,9 +248,11 @@ async function replyViaOpenAiAccount(params: {
|
||||
|
||||
const generated = await generateOpenAiReply({
|
||||
apiKey: params.account.apiKey,
|
||||
model: params.account.model || "gpt-5.4",
|
||||
model: params.agentControls?.modelOverride || params.account.model || "gpt-5.4",
|
||||
reasoningEffort: params.agentControls?.reasoningEffortOverride || "medium",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls: params.agentControls,
|
||||
});
|
||||
|
||||
await appendMasterAgentSystemReply(generated.content, params.senderLabel);
|
||||
@@ -240,8 +273,10 @@ async function replyViaOpenAiAccount(params: {
|
||||
async function generateOpenAiReply(params: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
}) {
|
||||
const state = await readState();
|
||||
let response: Response;
|
||||
@@ -254,9 +289,14 @@ async function generateOpenAiReply(params: {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
reasoning: { effort: "medium" },
|
||||
reasoning: { effort: params.reasoningEffort },
|
||||
instructions: buildMasterAgentInstructions(),
|
||||
input: buildRuntimeDigest(state, params.requestText, params.currentSessionExpiresAt),
|
||||
input: buildRuntimeDigest(
|
||||
state,
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
params.agentControls,
|
||||
),
|
||||
}),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
@@ -296,6 +336,120 @@ async function generateOpenAiReply(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMasterOpenAiReplyPrompt(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
agentControls?: ProjectAgentControls | null,
|
||||
) {
|
||||
return [
|
||||
buildMasterAgentInstructions(),
|
||||
"",
|
||||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt, agentControls),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function queueAndStartOpenAiMasterAgentReply(params: {
|
||||
taskId: string;
|
||||
deviceId: string;
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
}) {
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
const task = await getMasterAgentTask(params.taskId);
|
||||
if (!task || task.status !== "queued") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const generated = await generateOpenAiReply({
|
||||
apiKey: params.apiKey,
|
||||
model: params.model,
|
||||
reasoningEffort: params.reasoningEffort,
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls: params.agentControls,
|
||||
});
|
||||
|
||||
await completeMasterAgentTask({
|
||||
taskId: params.taskId,
|
||||
deviceId: params.deviceId,
|
||||
status: "completed",
|
||||
replyBody: generated.content,
|
||||
requestId: generated.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
await completeMasterAgentTask({
|
||||
taskId: params.taskId,
|
||||
deviceId: params.deviceId,
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : "主 Agent 当前调用模型失败。",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, 0);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
async function enqueueOpenAiMasterAgentReply(params: {
|
||||
accountId: string;
|
||||
accountLabel: string;
|
||||
requestMessageId?: string;
|
||||
requestText: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
agentControls?: ProjectAgentControls | null;
|
||||
}) {
|
||||
const state = await readState();
|
||||
const task = await queueMasterAgentTask({
|
||||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||||
requestText: params.requestText,
|
||||
executionPrompt: buildMasterOpenAiReplyPrompt(
|
||||
state,
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
params.agentControls,
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId: OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
accountId: params.accountId,
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
void queueAndStartOpenAiMasterAgentReply({
|
||||
taskId: task.taskId,
|
||||
deviceId: OPENAI_MASTER_AGENT_DEVICE_ID,
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
apiKey: params.apiKey,
|
||||
model: params.model,
|
||||
reasoningEffort: params.reasoningEffort,
|
||||
agentControls: params.agentControls,
|
||||
});
|
||||
|
||||
const queuedReply: QueuedMasterAgentReplyEnvelope = {
|
||||
ok: true as const,
|
||||
accountId: params.accountId,
|
||||
taskId: task.taskId,
|
||||
masterReplyState: "queued" as const,
|
||||
task: {
|
||||
taskId: task.taskId,
|
||||
taskType: "conversation_reply" as const,
|
||||
status: "queued" as const,
|
||||
},
|
||||
};
|
||||
return queuedReply;
|
||||
}
|
||||
|
||||
export async function probeOpenAiApiAccount(params: {
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
@@ -366,14 +520,16 @@ function buildMasterCodexNodePrompt(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
agentControls?: ProjectAgentControls | null,
|
||||
) {
|
||||
return [
|
||||
"你是 Boss 控制台的主 Agent,运行在用户自己的 Master Codex Node 上。",
|
||||
"请结合下面的运行时状态和用户消息,直接给出中文回复。",
|
||||
"如果你认为需要继续在当前仓库里推进实现、排障或验证,可以直接说明你下一步会做什么;如果必须先做交接或收尾,也要明确说出原因。",
|
||||
"保持简洁,优先给出结论、动作、验证点。",
|
||||
buildAgentControlsDigest(agentControls),
|
||||
"",
|
||||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt),
|
||||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt, agentControls),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -1039,8 +1195,10 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
mode?: "wait" | "enqueue";
|
||||
}) {
|
||||
const runtime = await getMasterAgentRuntimeAccount();
|
||||
const agentControls = await getProjectAgentControls("master-agent");
|
||||
|
||||
if (!runtime?.account) {
|
||||
await appendMasterAgentSystemReply(
|
||||
@@ -1049,6 +1207,96 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
return { ok: false as const, reason: "NO_AI_ACCOUNT" };
|
||||
}
|
||||
|
||||
if (params.mode === "enqueue") {
|
||||
if (runtime.account.provider === "master_codex_node") {
|
||||
const state = await readState();
|
||||
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
|
||||
const boundDevice = state.devices.find((device) => device.id === deviceId);
|
||||
const boundNodeLabel =
|
||||
runtime.account.nodeLabel?.trim() ||
|
||||
boundDevice?.name ||
|
||||
state.user.boundCodexNodeLabel ||
|
||||
deviceId;
|
||||
|
||||
if (!boundDevice || boundDevice.status !== "online") {
|
||||
await updateAiAccountHealth({
|
||||
accountId: runtime.account.accountId,
|
||||
status: "degraded",
|
||||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId);
|
||||
if (fallbackAccount?.apiKey?.trim()) {
|
||||
return enqueueOpenAiMasterAgentReply({
|
||||
accountId: fallbackAccount.accountId,
|
||||
accountLabel: fallbackAccount.label || aiRoleLabel(fallbackAccount.role),
|
||||
requestMessageId: params.requestMessageId,
|
||||
requestText: params.requestText,
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
apiKey: fallbackAccount.apiKey,
|
||||
model: agentControls?.modelOverride || fallbackAccount.model || "gpt-5.4",
|
||||
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
|
||||
agentControls,
|
||||
});
|
||||
}
|
||||
|
||||
await appendMasterAgentSystemReply(
|
||||
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`,
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
|
||||
}
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||||
requestText: params.requestText,
|
||||
executionPrompt: buildMasterCodexNodePrompt(
|
||||
state,
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
agentControls,
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId,
|
||||
accountId: runtime.account.accountId,
|
||||
accountLabel: runtime.account.label || runtime.summary.roleLabel,
|
||||
});
|
||||
|
||||
const queuedReply: QueuedMasterAgentReplyEnvelope = {
|
||||
ok: true as const,
|
||||
accountId: runtime.account.accountId,
|
||||
taskId: task.taskId,
|
||||
masterReplyState: "queued" as const,
|
||||
task: {
|
||||
taskId: task.taskId,
|
||||
taskType: "conversation_reply" as const,
|
||||
status: "queued" as const,
|
||||
},
|
||||
};
|
||||
return queuedReply;
|
||||
}
|
||||
|
||||
if (runtime.account.provider === "openai_api" && runtime.account.apiKey?.trim()) {
|
||||
return enqueueOpenAiMasterAgentReply({
|
||||
accountId: runtime.account.accountId,
|
||||
accountLabel: runtime.account.label || runtime.summary.roleLabel,
|
||||
requestMessageId: params.requestMessageId,
|
||||
requestText: params.requestText,
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
apiKey: runtime.account.apiKey,
|
||||
model: agentControls?.modelOverride || runtime.account.model || "gpt-5.4",
|
||||
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
|
||||
agentControls,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (runtime.account.provider === "master_codex_node") {
|
||||
const state = await readState();
|
||||
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
|
||||
@@ -1074,6 +1322,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`,
|
||||
agentControls,
|
||||
});
|
||||
} catch {
|
||||
// Fall through to the original offline guidance when the fallback API account cannot respond.
|
||||
@@ -1093,6 +1342,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
state,
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
agentControls,
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
@@ -1118,6 +1368,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`,
|
||||
agentControls,
|
||||
});
|
||||
} catch {
|
||||
// Preserve the original execution failure below if the fallback account also fails.
|
||||
@@ -1156,9 +1407,11 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
try {
|
||||
const generated = await generateOpenAiReply({
|
||||
apiKey: runtime.account.apiKey,
|
||||
model: runtime.account.model || "gpt-5.4",
|
||||
model: agentControls?.modelOverride || runtime.account.model || "gpt-5.4",
|
||||
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
agentControls,
|
||||
});
|
||||
|
||||
await appendMasterAgentSystemReply(
|
||||
|
||||
236
tests/master-agent-message-queue.test.ts
Normal file
236
tests/master-agent-message-queue.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
||||
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
|
||||
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-message-queue-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [messageRoute, data, auth] = await Promise.all([
|
||||
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
|
||||
POST = messageRoute.POST;
|
||||
saveAiAccount = data.saveAiAccount;
|
||||
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||
readState = data.readState;
|
||||
createAuthSession = data.createAuthSession;
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
async function createAuthedRequest(projectId: string, body: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => Promise<boolean>, timeoutMs = 5_000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("waitFor timed out");
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "openai-master-agent-queue",
|
||||
label: "API 容灾",
|
||||
role: "api_fallback",
|
||||
provider: "openai_api",
|
||||
displayName: "OpenAI API 队列测试",
|
||||
model: "gpt-5.4",
|
||||
apiKey: "sk-test-openai-queue",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "用于 master-agent 队列测试。",
|
||||
});
|
||||
|
||||
await updateProjectAgentControls("master-agent", {
|
||||
modelOverride: "gpt-4.1-mini",
|
||||
reasoningEffortOverride: "high",
|
||||
});
|
||||
|
||||
const fetchCalls: Array<{ url: string; body: unknown }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body ?? null;
|
||||
fetchCalls.push({ url: String(input), body });
|
||||
return new Response(JSON.stringify({ output_text: "已切到异步队列回复。" }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req-master-agent-queue",
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await POST(
|
||||
await createAuthedRequest("master-agent", {
|
||||
body: "请同步 master-agent 当前阻塞点",
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
task?: { taskId: string; taskType: string; status: string } | null;
|
||||
masterReplyState?: "queued" | "running" | "completed";
|
||||
masterReply?: unknown;
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.masterReplyState, "queued");
|
||||
assert.ok(payload.task, "expected master-agent message to return a task envelope");
|
||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||
assert.equal(payload.task?.status, "queued");
|
||||
assert.ok(payload.task?.taskId, "expected a stable taskId in the response");
|
||||
|
||||
await waitFor(async () => {
|
||||
const state = await readState();
|
||||
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||||
return task?.status === "completed";
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||||
assert.ok(task, "expected the queued task to remain in state");
|
||||
assert.equal(task?.status, "completed");
|
||||
assert.equal(task?.replyBody, "已切到异步队列回复。");
|
||||
|
||||
const masterProject = nextState.projects.find((project) => project.id === "master-agent");
|
||||
const mirroredReply = masterProject?.messages.at(-1);
|
||||
assert.ok(mirroredReply, "expected the async reply to be written back to the master-agent ledger");
|
||||
assert.match(mirroredReply?.body ?? "", /已切到异步队列回复/);
|
||||
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0]?.url, "https://api.openai.com/v1/responses");
|
||||
const requestBody = fetchCalls[0]?.body as {
|
||||
model?: string;
|
||||
reasoning?: { effort?: string };
|
||||
};
|
||||
assert.equal(requestBody?.model, "gpt-4.1-mini");
|
||||
assert.equal(requestBody?.reasoning?.effort, "high");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队列而不是挂到本机设备队列", async () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary-offline",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "离线 Master Codex Node",
|
||||
nodeId: "offline-node",
|
||||
nodeLabel: "离线节点",
|
||||
model: "gpt-5.4",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "离线主节点",
|
||||
});
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "openai-backup-queue",
|
||||
label: "备用 GPT",
|
||||
role: "backup",
|
||||
provider: "openai_api",
|
||||
displayName: "OpenAI 备用账号",
|
||||
accountIdentifier: "sk-queue-demo",
|
||||
model: "gpt-5.4",
|
||||
apiKey: "sk-queue-demo",
|
||||
enabled: true,
|
||||
setActive: false,
|
||||
loginStatusNote: "备用 API 账号",
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ output_text: "离线主节点已切到 API 后台队列。" }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-request-id": "req-master-agent-offline-fallback-queue",
|
||||
},
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await POST(
|
||||
await createAuthedRequest("master-agent", {
|
||||
body: "请走备用 API 队列",
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
task?: { taskId: string; taskType: string; status: string } | null;
|
||||
masterReplyState?: "queued" | "running" | "completed";
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.masterReplyState, "queued");
|
||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||
|
||||
await waitFor(async () => {
|
||||
const state = await readState();
|
||||
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||||
return task?.status === "completed";
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||||
assert.equal(task?.deviceId, "master-agent-openai");
|
||||
assert.equal(task?.status, "completed");
|
||||
assert.equal(task?.accountId, "openai-backup-queue");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user