feat: queue master-agent chat replies

This commit is contained in:
kris
2026-03-31 19:59:08 +08:00
parent e741952295
commit 013d9566be
8 changed files with 930 additions and 17 deletions

View File

@@ -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(

View File

@@ -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);

View File

@@ -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"));

View File

@@ -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;
}
}

View File

@@ -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()

View File

@@ -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,

View File

@@ -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-Age2592000 秒。",
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(

View 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;
}
});