3 Commits

Author SHA1 Message Date
kris
f496838ced Add second batch master agent fast paths 2026-04-16 05:01:38 +08:00
kris
514971bef8 Fix master agent model switch availability copy 2026-04-16 04:45:41 +08:00
kris
39be49630f Integrate master agent runtime orchestration updates 2026-04-16 04:41:46 +08:00
81 changed files with 9831 additions and 448 deletions

View File

@@ -119,6 +119,10 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
}
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
}
public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null);
}
@@ -134,6 +138,76 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = buildProjectAgentControlsPayload(
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
false
);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride,
boolean includeAdvancedOverrides
) throws IOException, JSONException {
JSONObject payload = buildProjectAgentControlsPayload(
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
includeAdvancedOverrides
);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
static JSONObject buildProjectAgentControlsPayload(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride,
boolean includeAdvancedOverrides
) throws JSONException {
JSONObject payload = new JSONObject();
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
boolean hasAdvancedOverrides =
fastModelOverride != null
|| fastReasoningEffortOverride != null
|| smartModelOverride != null
|| smartReasoningEffortOverride != null;
if (includeAdvancedOverrides || hasAdvancedOverrides) {
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
payload.put("fastReasoningEffortOverride", fastReasoningEffortOverride == null ? JSONObject.NULL : fastReasoningEffortOverride);
payload.put("smartModelOverride", smartModelOverride == null ? JSONObject.NULL : smartModelOverride);
payload.put("smartReasoningEffortOverride", smartReasoningEffortOverride == null ? JSONObject.NULL : smartReasoningEffortOverride);
}
return payload;
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@@ -263,10 +337,6 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload);
}
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
}
public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null);
}

View File

@@ -26,6 +26,11 @@ import androidx.annotation.Nullable;
import androidx.core.widget.ImageViewCompat;
import android.content.res.ColorStateList;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public final class BossUi {
private static final int[] AVATAR_BG_COLORS = {
Color.parseColor("#1EC76F"),
@@ -1229,6 +1234,195 @@ public final class BossUi {
return buildMessageBubble(context, effectiveSender, body, "发送中", true, null);
}
public static LinearLayout buildExecutionWarningCard(
Context context,
String title,
@Nullable String summary,
boolean outgoing
) {
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.topMargin = dp(context, 8);
card.setLayoutParams(params);
card.setPadding(dp(context, 12), dp(context, 10), dp(context, 12), dp(context, 10));
card.setBackground(createRoundedBackground(
outgoing ? Color.parseColor("#FFF1D7") : Color.parseColor("#FFF7E7"),
dp(context, 14)
));
TextView titleView = new TextView(context);
titleView.setText(TextUtils.isEmpty(title) ? "执行提醒" : title);
titleView.setTextSize(12);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextColor(Color.parseColor("#B36B00"));
card.addView(titleView);
if (!TextUtils.isEmpty(summary)) {
TextView summaryView = new TextView(context);
summaryView.setText(summary);
summaryView.setTextSize(13);
summaryView.setLineSpacing(0f, 1.2f);
summaryView.setTextColor(context.getColor(R.color.boss_text_primary));
summaryView.setPadding(0, dp(context, 4), 0, 0);
summaryView.setMaxWidth(Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.62f));
summaryView.setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY);
summaryView.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL);
card.addView(summaryView);
}
return card;
}
public static LinearLayout buildMessageStatusRow(
Context context,
JSONObject message,
@Nullable JSONObject conversationTask,
List<JSONObject> warnings,
boolean outgoing
) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
rowParams.topMargin = dp(context, 8);
row.setLayoutParams(rowParams);
boolean hasTask = conversationTask != null;
boolean hasWarnings = warnings != null && !warnings.isEmpty();
String detailText = buildStatusDetailText(message, conversationTask, warnings);
boolean hasDetail = !TextUtils.isEmpty(detailText);
if (!hasTask && !hasWarnings && !hasDetail) {
row.setVisibility(View.GONE);
return row;
}
if (hasTask || hasWarnings) {
LinearLayout pillRow = new LinearLayout(context);
pillRow.setOrientation(LinearLayout.HORIZONTAL);
pillRow.setGravity(Gravity.CENTER_VERTICAL);
row.addView(pillRow);
if (hasTask) {
pillRow.addView(buildStatusPill(
context,
taskStatusLabel(conversationTask.optString("status", "")),
outgoing ? Color.parseColor("#D7F3DF") : Color.parseColor("#EDF7F0"),
Color.parseColor("#215B39")
));
}
if (hasWarnings) {
if (pillRow.getChildCount() > 0) {
View spacer = new View(context);
LinearLayout.LayoutParams spacerParams = new LinearLayout.LayoutParams(dp(context, 6), 1);
spacer.setLayoutParams(spacerParams);
pillRow.addView(spacer);
}
pillRow.addView(buildStatusPill(
context,
warningBadgeLabel(warnings),
outgoing ? Color.parseColor("#FFF1D7") : Color.parseColor("#FFF7E7"),
Color.parseColor("#B36B00")
));
}
}
if (hasDetail) {
TextView detailView = new TextView(context);
detailView.setText(detailText);
detailView.setTextSize(12);
detailView.setTextColor(context.getColor(R.color.boss_text_muted));
detailView.setPadding(dp(context, 2), dp(context, 4), dp(context, 2), 0);
detailView.setMaxLines(2);
detailView.setEllipsize(TextUtils.TruncateAt.END);
detailView.setMaxWidth(Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.62f));
row.addView(detailView);
}
return row;
}
private static TextView buildStatusPill(
Context context,
String text,
int backgroundColor,
int textColor
) {
TextView pill = new TextView(context);
pill.setText(text);
pill.setTextSize(11);
pill.setTypeface(Typeface.DEFAULT_BOLD);
pill.setTextColor(textColor);
pill.setPadding(dp(context, 10), dp(context, 5), dp(context, 10), dp(context, 5));
pill.setBackground(createRoundedBackground(backgroundColor, dp(context, 12)));
return pill;
}
private static String taskStatusLabel(String status) {
switch (status) {
case "queued":
return "排队中";
case "running":
return "执行中";
case "completed":
return "已完成";
case "failed":
return "已失败";
default:
return TextUtils.isEmpty(status) ? "处理中" : status;
}
}
private static String warningBadgeLabel(List<JSONObject> warnings) {
int totalWarnings = 0;
for (JSONObject warning : warnings) {
if (warning == null) {
continue;
}
totalWarnings += Math.max(1, warning.optInt("count", 1));
}
return totalWarnings > 1 ? "提醒 " + totalWarnings : "执行提醒";
}
private static String buildStatusDetailText(
JSONObject message,
@Nullable JSONObject conversationTask,
List<JSONObject> warnings
) {
ArrayList<String> parts = new ArrayList<>();
if (conversationTask != null) {
String sessionId = conversationTask.optString("sessionId", "");
String taskId = conversationTask.optString("taskId", "");
if (!TextUtils.isEmpty(sessionId)) {
parts.add("Session " + sessionId);
} else if (!TextUtils.isEmpty(taskId)) {
parts.add("Task " + taskId);
}
}
if (warnings != null && !warnings.isEmpty()) {
JSONObject firstWarning = warnings.get(0);
if (firstWarning != null) {
String summary = firstWarning.optString("summary", "");
String title = firstWarning.optString("title", "执行提醒");
int count = Math.max(1, firstWarning.optInt("count", 1));
String warningText = !TextUtils.isEmpty(summary) ? summary : title;
if (count > 1) {
warningText = warningText + "" + count + "";
} else if (warnings.size() > 1) {
warningText = warningText + "" + warnings.size() + "";
}
parts.add(warningText);
}
} else if ("system_notice".equals(message.optString("kind", ""))) {
parts.add("系统同步提醒");
}
return TextUtils.join(" · ", parts);
}
public static void applyMessageSelectionState(Context context, View messageView, boolean selected) {
if (messageView == null) {
return;

View File

@@ -76,7 +76,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
try {
LoadedConversation loadedConversation = loadConversation();
BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse;
BossApiClient.ApiResponse participantsResponse = loadedConversation.participantsResponse;
JSONObject participantsPayload = loadedConversation.participantsPayload;
JSONObject threadStatusPayload = null;
try {
BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse;
@@ -87,7 +87,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
threadStatusPayload = null;
}
JSONObject finalThreadStatusPayload = threadStatusPayload;
runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json, finalThreadStatusPayload));
runOnUiThread(() -> renderConversation(detailResponse.json, participantsPayload, finalThreadStatusPayload));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -471,13 +471,14 @@ public class ConversationInfoActivity extends BossScreenActivity {
throw new IllegalStateException(detailResponse.message());
}
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
if (!participantsResponse.ok()) {
throw new IllegalStateException(participantsResponse.message());
}
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId);
return new LoadedConversation(detailResponse, participantsResponse, threadStatusResponse);
return new LoadedConversation(detailResponse, participantsPayload, threadStatusResponse);
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
return participantsPayload == null ? new JSONObject() : participantsPayload;
}
private BossApiClient.ApiResponse saveTakeoverSettingsWithRetry(
@@ -548,16 +549,16 @@ public class ConversationInfoActivity extends BossScreenActivity {
private static final class LoadedConversation {
private final BossApiClient.ApiResponse detailResponse;
private final BossApiClient.ApiResponse participantsResponse;
private final JSONObject participantsPayload;
private final BossApiClient.ApiResponse threadStatusResponse;
private LoadedConversation(
BossApiClient.ApiResponse detailResponse,
BossApiClient.ApiResponse participantsResponse,
JSONObject participantsPayload,
BossApiClient.ApiResponse threadStatusResponse
) {
this.detailResponse = detailResponse;
this.participantsResponse = participantsResponse;
this.participantsPayload = participantsPayload;
this.threadStatusResponse = threadStatusResponse;
}
}

View File

@@ -11,6 +11,7 @@ import android.widget.TextView;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
@@ -70,9 +71,10 @@ public class GroupCreateActivity extends BossScreenActivity {
runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true));
return;
}
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId);
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true));
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(sourceProjectId);
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json, sourceProjectId);
runOnUiThread(() -> renderCreatePage(participantsPayload, conversationsResponse.json, true));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -155,6 +157,23 @@ public class GroupCreateActivity extends BossScreenActivity {
updateCreateButtonState();
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload, String fallbackProjectId) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
if (participantsPayload != null) {
return participantsPayload;
}
JSONObject fallback = new JSONObject();
JSONObject project = detailPayload == null ? null : detailPayload.optJSONObject("project");
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
try {
fallback.put("projectId", fallbackProjectId == null ? "" : fallbackProjectId);
fallback.put("threadMeta", threadMeta == null ? new JSONObject() : threadMeta);
fallback.put("participants", new JSONArray());
} catch (JSONException ignored) {
}
return fallback;
}
private View buildHeaderView(
boolean hasSourceProject,
@Nullable String sourceProjectId,

View File

@@ -22,6 +22,7 @@ public class GroupInfoActivity extends BossScreenActivity {
private String projectId;
private String projectName;
private boolean groupRepairJustApplied;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@@ -72,13 +73,12 @@ public class GroupInfoActivity extends BossScreenActivity {
try {
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId);
JSONObject orchestrationBackend = orchestrationResponse.ok()
? orchestrationResponse.json
: buildFallbackOrchestrationBackendPayload(orchestrationResponse.message());
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json, orchestrationBackend));
runOnUiThread(() -> renderGroup(detailResponse.json, participantsPayload, orchestrationBackend));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -149,6 +149,11 @@ public class GroupInfoActivity extends BossScreenActivity {
}
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
return participantsPayload == null ? new JSONObject() : participantsPayload;
}
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
renderGroup(detail, participantsPayload, null);
}
@@ -174,6 +179,11 @@ public class GroupInfoActivity extends BossScreenActivity {
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
configureScreen("群资料", buildSubtitle(folderName, participantCount));
if (groupRepairJustApplied) {
appendContent(BossUi.buildEmptyCard(this, "群成员已更新,当前群聊已经切换到新的真实线程成员。"));
groupRepairJustApplied = false;
}
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName,
@@ -367,6 +377,7 @@ public class GroupInfoActivity extends BossScreenActivity {
BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
groupRepairJustApplied = true;
showMessage("群成员已更新");
reload();
});

View File

@@ -62,10 +62,26 @@ public final class ProjectChatUiState {
public static final class ReplyWaitSpec {
public final boolean shouldWait;
public final String baselineMessageId;
public final List<String> executionIds;
private ReplyWaitSpec(boolean shouldWait, @Nullable String baselineMessageId) {
this.shouldWait = shouldWait && !isBlank(baselineMessageId);
this.baselineMessageId = this.shouldWait ? baselineMessageId.trim() : "";
private ReplyWaitSpec(
boolean shouldWait,
@Nullable String baselineMessageId,
@Nullable List<String> executionIds
) {
ArrayList<String> normalizedExecutionIds = new ArrayList<>();
if (executionIds != null) {
for (String executionId : executionIds) {
if (!isBlank(executionId)) {
normalizedExecutionIds.add(executionId.trim());
}
}
}
boolean hasBaseline = !isBlank(baselineMessageId);
boolean hasExecutionIds = !normalizedExecutionIds.isEmpty();
this.shouldWait = shouldWait && (hasBaseline || hasExecutionIds);
this.baselineMessageId = hasBaseline ? baselineMessageId.trim() : "";
this.executionIds = Collections.unmodifiableList(normalizedExecutionIds);
}
}
@@ -410,30 +426,81 @@ public final class ProjectChatUiState {
public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) {
if (response == null) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
JSONObject task = response.optJSONObject("task");
if (task == null) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
String taskStatus = task.optString("status", "");
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
JSONObject message = response.optJSONObject("message");
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""), null);
}
public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) {
if (response == null) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
JSONArray executions = response.optJSONArray("executions");
if (executions == null || executions.length() == 0) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
JSONObject notice = response.optJSONObject("notice");
return new ReplyWaitSpec(true, notice == null ? null : notice.optString("id", ""));
return new ReplyWaitSpec(
true,
notice == null ? null : notice.optString("id", ""),
collectExecutionIds(executions)
);
}
public static ReplyWaitSpec replyWaitFromBaseline(@Nullable String baselineMessageId) {
return new ReplyWaitSpec(true, baselineMessageId, null);
}
public static boolean hasTrackedDispatchExecutionReply(
@Nullable JSONArray dispatchPlans,
@Nullable List<String> executionIds
) {
if (dispatchPlans == null || executionIds == null || executionIds.isEmpty()) {
return false;
}
LinkedHashSet<String> trackedIds = new LinkedHashSet<>();
for (String executionId : executionIds) {
if (!isBlank(executionId)) {
trackedIds.add(executionId.trim());
}
}
if (trackedIds.isEmpty()) {
return false;
}
for (int i = 0; i < dispatchPlans.length(); i++) {
JSONObject plan = dispatchPlans.optJSONObject(i);
if (plan == null) {
continue;
}
JSONArray executions = plan.optJSONArray("executions");
if (executions == null) {
continue;
}
for (int j = 0; j < executions.length(); j++) {
JSONObject execution = executions.optJSONObject(j);
if (execution == null) {
continue;
}
String executionId = execution.optString("executionId", "").trim();
if (!trackedIds.contains(executionId)) {
continue;
}
String status = execution.optString("status", "").trim();
if ("completed".equals(status) || "failed".equals(status)) {
return true;
}
}
}
return false;
}
public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) {
@@ -457,6 +524,24 @@ public final class ProjectChatUiState {
return messageId.isEmpty() ? null : messageId;
}
private static List<String> collectExecutionIds(@Nullable JSONArray executions) {
ArrayList<String> executionIds = new ArrayList<>();
if (executions == null) {
return executionIds;
}
for (int i = 0; i < executions.length(); i++) {
JSONObject execution = executions.optJSONObject(i);
if (execution == null) {
continue;
}
String executionId = execution.optString("executionId", "").trim();
if (!executionId.isEmpty()) {
executionIds.add(executionId);
}
}
return executionIds;
}
private static boolean isBlank(@Nullable String value) {
return value == null || value.trim().isEmpty();
}

View File

@@ -36,6 +36,7 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -55,6 +56,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
private String projectFolderName;
private @Nullable String currentAgentModelOverride;
private @Nullable String currentReasoningEffortOverride;
private @Nullable String currentFastModelOverride;
private @Nullable String currentFastReasoningEffortOverride;
private @Nullable String currentSmartModelOverride;
private @Nullable String currentSmartReasoningEffortOverride;
private final List<String> currentAvailableMasterAgentModels = new ArrayList<>();
private final List<String> currentSelectableMasterAgentModels = new ArrayList<>();
private LinearLayout quickActionsLayout;
private LinearLayout composerRow;
private LinearLayout multiSelectActionsLayout;
@@ -174,6 +181,25 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
}
static @Nullable JSONArray extractDispatchPlans(JSONObject detailPayload) {
return detailPayload == null ? null : detailPayload.optJSONArray("dispatchPlans");
}
static @Nullable JSONObject extractParticipantsPayload(JSONObject detailPayload) {
return detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
}
private String buildDisplayedProjectTitle(@Nullable String rawTitle) {
String normalizedTitle = TextUtils.isEmpty(rawTitle) ? "项目详情" : rawTitle;
if (!isMasterAgentConversation()) {
return normalizedTitle;
}
String modelName = !TextUtils.isEmpty(currentAgentModelOverride)
? currentAgentModelOverride
: (!TextUtils.isEmpty(currentSmartModelOverride) ? currentSmartModelOverride : null);
return TextUtils.isEmpty(modelName) ? "主Agent" : "主Agent·" + modelName;
}
@Override
protected int getLayoutResId() {
return R.layout.activity_project_chat;
@@ -273,7 +299,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
BossWindowInsets.applyKeyboardAvoidingInset(composerRow);
BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout);
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
updateProjectHeader(buildDisplayedProjectTitle(initialProjectName), "正在同步项目详情...");
if (composerAttachmentButton != null) {
composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet());
}
@@ -371,12 +397,19 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (projectMessagesPayload == null) {
return false;
}
if (shouldBypassRealtimeMessagesPatchForGroupState()) {
return false;
}
JSONArray executionWarnings = projectMessagesPayload.optJSONArray("executionWarnings");
if (trySkipUnchangedRealtimeMessagesPatch(projectMessagesPayload)) {
return true;
}
if (tryAppendRealtimeMessagesPatch(projectMessagesPayload)) {
return true;
}
if (tryPatchRealtimeExecutionWarnings(projectMessagesPayload)) {
return true;
}
runOnUiThread(() -> {
if (reloadInFlight) {
scheduleRealtimeReload(false);
@@ -389,6 +422,16 @@ public class ProjectDetailActivity extends BossScreenActivity {
return true;
}
private boolean shouldBypassRealtimeMessagesPatchForGroupState() {
if (!projectIsGroup) {
return false;
}
if (currentPendingDispatchPlan != null || currentRejectedDispatchPlan != null) {
return true;
}
return currentParticipantsPayload != null && currentParticipantsPayload.optBoolean("repairRequired", false);
}
private boolean trySkipUnchangedRealtimeMessagesPatch(JSONObject projectMessagesPayload) {
if (currentRenderedProjectPayload == null || projectMessagesPayload == null) {
return false;
@@ -412,6 +455,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!TextUtils.equals(currentMessages.toString(), nextMessages.toString())) {
return false;
}
if (!hasMatchingExecutionWarnings(currentRenderedProjectPayload, projectMessagesPayload)) {
return false;
}
if (!hasMatchingConversationTasks(currentRenderedProjectPayload, projectMessagesPayload)) {
return false;
}
currentRenderedProjectPayload = copyJson(projectMessagesPayload);
return true;
}
@@ -435,6 +484,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (currentMessages == null || nextMessages == null) {
return false;
}
if (!hasMatchingExecutionWarnings(currentRenderedProjectPayload, projectMessagesPayload)) {
return false;
}
if (!hasMatchingConversationTasks(currentRenderedProjectPayload, projectMessagesPayload)) {
return false;
}
List<String> currentIds = collectMessageIds(currentMessages);
List<String> nextIds = collectMessageIds(nextMessages);
if (currentIds.isEmpty() || nextIds.size() <= currentIds.size()) {
@@ -460,7 +515,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", projectCollaborationMode);
projectApprovalState = project == null ? "not_required" : project.optString("approvalState", projectApprovalState);
lightDispatchReminderEnabled = project != null && project.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled);
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
updateProjectHeader(buildDisplayedProjectTitle(title), buildProjectSubtitle(projectFolderName, devices));
selectionState = ProjectChatUiState.reconcileSelection(selectionState, nextIds);
renderNearBottom = isChatNearBottom();
@@ -483,6 +538,112 @@ public class ProjectDetailActivity extends BossScreenActivity {
return true;
}
private boolean tryPatchRealtimeExecutionWarnings(JSONObject projectMessagesPayload) {
if (currentRenderedProjectPayload == null
|| contentLayout == null
|| pendingOutgoingBubble != null
|| masterAgentReplyWaiting
|| masterAgentReplyTimedOut
|| (selectionState != null && selectionState.multiSelecting)) {
return false;
}
JSONObject currentProject = currentRenderedProjectPayload.optJSONObject("project");
JSONObject nextProject = projectMessagesPayload.optJSONObject("project");
if (currentProject == null || nextProject == null) {
return false;
}
JSONArray currentMessages = currentProject.optJSONArray("messages");
JSONArray nextMessages = nextProject.optJSONArray("messages");
if (currentMessages == null || nextMessages == null) {
return false;
}
if (!TextUtils.equals(currentMessages.toString(), nextMessages.toString())) {
return false;
}
if (hasMatchingExecutionWarnings(currentRenderedProjectPayload, projectMessagesPayload)
&& hasMatchingConversationTasks(currentRenderedProjectPayload, projectMessagesPayload)) {
return false;
}
JSONObject nextPayloadCopy = copyJson(projectMessagesPayload);
runOnUiThread(() -> {
if (currentRenderedProjectPayload == null || contentLayout == null) {
renderLoadedProjectSnapshot(new ProjectSnapshot(projectMessagesPayload, null, null));
return;
}
JSONArray refreshedMessages = nextProject.optJSONArray("messages");
if (refreshedMessages == null) {
renderLoadedProjectSnapshot(new ProjectSnapshot(projectMessagesPayload, null, null));
return;
}
renderNearBottom = isChatNearBottom();
runWithSuppressedContentLayout(() -> {
for (int i = 0; i < refreshedMessages.length(); i++) {
JSONObject message = refreshedMessages.optJSONObject(i);
if (message == null) {
continue;
}
String messageId = message.optString("id", "");
if (TextUtils.isEmpty(messageId)) {
continue;
}
String currentFingerprint = buildStatusFingerprint(messageId, currentRenderedProjectPayload);
String nextFingerprint = buildStatusFingerprint(messageId, projectMessagesPayload);
if (TextUtils.isEmpty(currentFingerprint) && TextUtils.isEmpty(nextFingerprint)) {
continue;
}
if (!TextUtils.equals(currentFingerprint, nextFingerprint)) {
replaceMessageViewById(messageId, buildMessageView(message));
}
}
});
currentRenderedProjectPayload = nextPayloadCopy;
setRefreshing(false);
updateSelectionUi();
if (ProjectChatUiState.shouldAutoScroll(renderNearBottom, false)) {
scrollChatToBottom();
}
});
return true;
}
private boolean hasMatchingExecutionWarnings(JSONObject currentPayload, JSONObject nextPayload) {
if (currentPayload == null || nextPayload == null) {
return false;
}
JSONArray currentWarnings = currentPayload.optJSONArray("executionWarnings");
JSONArray nextWarnings = nextPayload.optJSONArray("executionWarnings");
String currentSerialized = currentWarnings == null ? "[]" : currentWarnings.toString();
String nextSerialized = nextWarnings == null ? "[]" : nextWarnings.toString();
return TextUtils.equals(currentSerialized, nextSerialized);
}
private boolean hasMatchingConversationTasks(JSONObject currentPayload, JSONObject nextPayload) {
if (currentPayload == null || nextPayload == null) {
return false;
}
JSONArray currentTasks = currentPayload.optJSONArray("conversationTasks");
JSONArray nextTasks = nextPayload.optJSONArray("conversationTasks");
String currentSerialized = currentTasks == null ? "[]" : currentTasks.toString();
String nextSerialized = nextTasks == null ? "[]" : nextTasks.toString();
return TextUtils.equals(currentSerialized, nextSerialized);
}
private void replaceMessageViewById(String messageId, View nextMessageView) {
if (TextUtils.isEmpty(messageId) || nextMessageView == null || contentLayout == null) {
return;
}
for (int i = 0; i < contentLayout.getChildCount(); i++) {
View child = contentLayout.getChildAt(i);
Object tag = child.getTag();
if (!(tag instanceof String) || !TextUtils.equals(messageId, (String) tag)) {
continue;
}
contentLayout.removeViewAt(i);
contentLayout.addView(nextMessageView, i);
return;
}
}
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
pruneRecentRealtimeEvents(now);
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
@@ -654,6 +815,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
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));
currentFastModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("fastModelOverride", null));
currentFastReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("fastReasoningEffortOverride", null));
currentSmartModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("smartModelOverride", null));
currentSmartReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("smartReasoningEffortOverride", null));
if (dispatchPlans != null) {
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
currentRejectedDispatchPlan = currentPendingDispatchPlan == null
@@ -667,7 +832,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
? currentParticipantsPayload
: participantsPayload;
conversationInfoReady = project != null;
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
updateProjectHeader(buildDisplayedProjectTitle(title), buildProjectSubtitle(projectFolderName, devices));
renderQuickActions();
JSONArray messages = project == null ? null : project.optJSONArray("messages");
@@ -1251,40 +1416,116 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!isMasterAgentConversation()) {
return;
}
final String[] options = buildMasterAgentModelOptions();
int checkedIndex = findCheckedIndex(options, currentAgentModelOverride);
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getProjectAgentControls(projectId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
JSONObject payload = response.json;
JSONObject controls = payload.optJSONObject("controls");
JSONObject modelCatalog = payload.optJSONObject("modelCatalog");
runOnUiThread(() -> {
applyMasterAgentControlsFromJson(controls);
applyMasterAgentModelCatalogFromJson(modelCatalog);
setRefreshing(false);
showMasterAgentModelScopeMenu();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("模型信息加载失败:" + error.getMessage());
});
}
});
}
private void showMasterAgentModelScopeMenu() {
String availableLabel = currentAvailableMasterAgentModels.isEmpty()
? "未检测到已就绪账号,可直接使用预设模型"
: "当前可用:" + TextUtils.join("", currentAvailableMasterAgentModels);
new AlertDialog.Builder(this)
.setTitle("模型")
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
.setItems(new CharSequence[]{
availableLabel,
buildMasterAgentModelScopeSummary("当前主模型", currentAgentModelOverride),
buildMasterAgentModelScopeSummary("快模型", currentFastModelOverride),
buildMasterAgentModelScopeSummary("强模型", currentSmartModelOverride)
}, (dialog, which) -> {
if (which == 0) {
dialog.dismiss();
updateMasterAgentControls(null, currentReasoningEffortOverride, "模型已恢复默认");
showMasterAgentAvailableModelsDialog();
return;
}
if (which == options.length - 1) {
if (which == 1) {
dialog.dismiss();
showCustomMasterAgentModelDialog();
showMasterAgentModelValuePicker("当前主模型", currentAgentModelOverride, "manual_override");
return;
}
dialog.dismiss();
updateMasterAgentControls(options[which], currentReasoningEffortOverride, "模型已更新为 " + options[which]);
if (which == 2) {
dialog.dismiss();
showMasterAgentModelValuePicker("快模型", currentFastModelOverride, "fast");
return;
}
if (which == 3) {
dialog.dismiss();
showMasterAgentModelValuePicker("强模型", currentSmartModelOverride, "smart");
}
})
.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);
private void showMasterAgentAvailableModelsDialog() {
String message = currentAvailableMasterAgentModels.isEmpty()
? "当前没有检测到已就绪账号,你仍然可以直接选择预设模型,或者用自定义模型手动填写。"
: "当前可用模型:\n" + TextUtils.join("\n", currentAvailableMasterAgentModels);
String selectable = currentSelectableMasterAgentModels.isEmpty()
? ""
: "\n\n可选模型\n" + TextUtils.join("\n", currentSelectableMasterAgentModels);
new AlertDialog.Builder(this)
.setTitle("自定义模型")
.setTitle("可用模型")
.setMessage(message + selectable)
.setPositiveButton("知道了", null)
.show();
}
private void showMasterAgentModelValuePicker(String scopeTitle, @Nullable String currentValue, String scopeKey) {
final String[] options = buildMasterAgentModelOptions(currentValue);
int checkedIndex = findCheckedIndex(options, currentValue);
new AlertDialog.Builder(this)
.setTitle(scopeTitle)
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
if (which == 0) {
dialog.dismiss();
updateMasterAgentControlsForScope(scopeKey, null, scopeTitle + "已恢复默认");
return;
}
if (which == options.length - 1) {
dialog.dismiss();
showCustomMasterAgentModelDialog(scopeTitle, scopeKey, currentValue);
return;
}
dialog.dismiss();
updateMasterAgentControlsForScope(scopeKey, options[which], scopeTitle + "已更新为 " + options[which]);
})
.setNegativeButton("取消", null)
.show();
}
private void showCustomMasterAgentModelDialog(String scopeTitle, String scopeKey, @Nullable String currentValue) {
final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
input.setText(TextUtils.isEmpty(currentValue) ? "gpt-5.4" : currentValue);
new AlertDialog.Builder(this)
.setTitle(scopeTitle + " · 自定义模型")
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) ->
updateMasterAgentControls(
updateMasterAgentControlsForScope(
scopeKey,
normalizeControlValue(input.getText() == null ? null : input.getText().toString()),
currentReasoningEffortOverride,
"模型已更新"
scopeTitle + "已更新"
))
.show();
}
@@ -1303,6 +1544,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
updateMasterAgentControls(
currentAgentModelOverride,
reasoningOverride,
currentFastModelOverride,
currentFastReasoningEffortOverride,
currentSmartModelOverride,
currentSmartReasoningEffortOverride,
which == 0 ? "推理强度已恢复默认" : "推理强度已更新为 " + options[which]
);
})
@@ -1313,6 +1558,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
private void updateMasterAgentControls(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride,
String successMessage
) {
if (!isMasterAgentConversation() || projectId == null || projectId.isEmpty()) {
@@ -1324,15 +1573,19 @@ public class ProjectDetailActivity extends BossScreenActivity {
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls(
projectId,
modelOverride,
reasoningEffortOverride
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
true
);
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));
applyMasterAgentControlsFromJson(controls);
showMessage(successMessage);
reload(true);
});
@@ -1345,21 +1598,86 @@ public class ProjectDetailActivity extends BossScreenActivity {
});
}
private String[] buildMasterAgentModelOptions() {
private void updateMasterAgentControlsForScope(String scopeKey, @Nullable String modelOverride, String successMessage) {
String manualModelOverride = currentAgentModelOverride;
String fastModelOverride = currentFastModelOverride;
String smartModelOverride = currentSmartModelOverride;
if ("fast".equals(scopeKey)) {
fastModelOverride = modelOverride;
} else if ("smart".equals(scopeKey)) {
smartModelOverride = modelOverride;
} else {
manualModelOverride = modelOverride;
}
updateMasterAgentControls(
manualModelOverride,
currentReasoningEffortOverride,
fastModelOverride,
currentFastReasoningEffortOverride,
smartModelOverride,
currentSmartReasoningEffortOverride,
successMessage
);
}
private void applyMasterAgentControlsFromJson(@Nullable JSONObject controls) {
currentAgentModelOverride = normalizeControlValue(controls == null ? null : controls.optString("modelOverride", null));
currentReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("reasoningEffortOverride", null));
currentFastModelOverride = normalizeControlValue(controls == null ? null : controls.optString("fastModelOverride", null));
currentFastReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("fastReasoningEffortOverride", null));
currentSmartModelOverride = normalizeControlValue(controls == null ? null : controls.optString("smartModelOverride", null));
currentSmartReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("smartReasoningEffortOverride", null));
if (isMasterAgentConversation()) {
updateProjectHeader(buildDisplayedProjectTitle(initialProjectName), currentScreenSubtitle);
}
}
private void applyMasterAgentModelCatalogFromJson(@Nullable JSONObject modelCatalog) {
currentAvailableMasterAgentModels.clear();
currentSelectableMasterAgentModels.clear();
addJsonArrayStrings(modelCatalog == null ? null : modelCatalog.optJSONArray("availableModels"), currentAvailableMasterAgentModels);
addJsonArrayStrings(modelCatalog == null ? null : modelCatalog.optJSONArray("selectableModels"), currentSelectableMasterAgentModels);
}
private void addJsonArrayStrings(@Nullable JSONArray array, List<String> output) {
if (array == null || output == null) {
return;
}
for (int i = 0; i < array.length(); i++) {
String value = normalizeControlValue(array.optString(i, null));
if (!TextUtils.isEmpty(value) && !output.contains(value)) {
output.add(value);
}
}
}
private String buildMasterAgentModelScopeSummary(String label, @Nullable String value) {
return label + "" + (TextUtils.isEmpty(value) ? "默认" : value);
}
private String[] buildMasterAgentModelOptions(@Nullable String currentValue) {
List<String> options = new ArrayList<>();
options.add("沿用默认");
if (!TextUtils.isEmpty(currentAgentModelOverride)) {
options.add(currentAgentModelOverride);
if (!TextUtils.isEmpty(currentValue) && !options.contains(currentValue)) {
options.add(currentValue);
}
for (String value : currentSelectableMasterAgentModels) {
if (!TextUtils.isEmpty(value) && !options.contains(value)) {
options.add(value);
}
}
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-5.4-mini")) {
options.add("gpt-5.4-mini");
}
if (!options.contains("gpt-4.1")) {
options.add("gpt-4.1");
}
if (!options.contains("gpt-4.1-mini")) {
options.add("gpt-4.1-mini");
}
options.add("自定义...");
return options.toArray(new String[0]);
}
@@ -1427,8 +1745,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
private View buildRepairGroupMembersView(JSONObject participantsPayload) {
String repairReason = participantsPayload.optString("repairReason", "当前群聊里有失效线程,请先修复群成员。");
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
int validParticipantCount = participantsPayload.optInt("validParticipantCount", 0);
String meta = invalidParticipantCount > 0
? "存在 " + invalidParticipantCount + " 个失效成员"
: validParticipantCount > 0
? "当前仅有 " + validParticipantCount + " 个真实线程成员"
: "当前群聊还没有可下发的真实线程";
LinearLayout container = new LinearLayout(this);
container.setOrientation(LinearLayout.VERTICAL);
@@ -1612,6 +1933,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
);
break;
}
JSONObject conversationTask = findConversationTask(currentRenderedProjectPayload, messageId);
if (messageView instanceof LinearLayout) {
LinearLayout wrapper = (LinearLayout) messageView;
List<JSONObject> messageWarnings = buildMessageWarnings(currentRenderedProjectPayload, messageId);
LinearLayout statusRow = BossUi.buildMessageStatusRow(this, message, conversationTask, messageWarnings, outgoing);
if (statusRow.getVisibility() != View.GONE) {
wrapper.addView(statusRow);
}
}
bindMessageInteractions(messageView, messageId, body, messagePrimaryClick);
return messageView;
}
@@ -2491,26 +2821,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!detailResponse.ok()) {
throw new IllegalStateException(detailResponse.message());
}
JSONArray dispatchPlans = null;
JSONObject participantsPayload = null;
if (includeDispatchPlans) {
try {
BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId);
if (dispatchPlansResponse.ok()) {
dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans");
}
} catch (Exception ignored) {
dispatchPlans = null;
}
try {
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
if (participantsResponse.ok()) {
participantsPayload = participantsResponse.json;
}
} catch (Exception ignored) {
participantsPayload = null;
}
}
JSONArray dispatchPlans = includeDispatchPlans
? detailResponse.json.optJSONArray("dispatchPlans")
: null;
JSONObject participantsPayload = includeDispatchPlans
? detailResponse.json.optJSONObject("participantsPayload")
: null;
return new ProjectSnapshot(detailResponse.json, dispatchPlans, participantsPayload);
}
@@ -2535,7 +2851,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
updateComposerSendButtonState();
setRefreshing(true);
showMessage(waitingMessage);
enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans);
enqueueReplyWaitPoll(waitSpec, includeDispatchPlans);
}
private void startMasterAgentReplyWait(
@@ -2551,15 +2867,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
setRefreshing(false);
showMessage(waitingMessage);
reload(true);
enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans);
enqueueReplyWaitPoll(waitSpec, includeDispatchPlans);
}
protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) {
replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans));
protected void enqueueReplyWaitPoll(ProjectChatUiState.ReplyWaitSpec waitSpec, boolean includeDispatchPlans) {
replyWaitExecutor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
}
private void pollUntilReply(
@Nullable String baselineMessageId,
ProjectChatUiState.ReplyWaitSpec waitSpec,
boolean includeDispatchPlans
) {
long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS;
@@ -2568,7 +2884,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) {
ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans);
JSONObject project = snapshot.payload.optJSONObject("project");
boolean hasReply = ProjectChatUiState.hasReplyBeyondBaseline(project, baselineMessageId);
boolean hasReply = !waitSpec.executionIds.isEmpty()
? ProjectChatUiState.hasTrackedDispatchExecutionReply(snapshot.dispatchPlans, waitSpec.executionIds)
: ProjectChatUiState.hasReplyBeyondBaseline(project, waitSpec.baselineMessageId);
if (!renderedInitialSnapshot || hasReply) {
runOnUiThread(() -> {
@@ -2633,7 +2951,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
setRefreshing(false);
showMessage("已重新开始等待主 Agent 回复");
reload(true);
enqueueReplyWaitPoll(masterAgentReplyBaselineMessageId, false);
enqueueReplyWaitPoll(
ProjectChatUiState.replyWaitFromBaseline(masterAgentReplyBaselineMessageId),
false
);
}
static ChromeBindings buildChromeBindings(
@@ -2718,6 +3039,148 @@ public class ProjectDetailActivity extends BossScreenActivity {
return ids;
}
@Nullable
private JSONObject findExecutionWarningForMessage(@Nullable String messageId) {
return findExecutionWarningForMessage(currentRenderedProjectPayload, messageId);
}
@Nullable
private JSONObject findExecutionWarningForMessage(@Nullable JSONObject payload, @Nullable String messageId) {
if (TextUtils.isEmpty(messageId) || payload == null) {
return null;
}
JSONArray warnings = payload.optJSONArray("executionWarnings");
if (warnings == null) {
return null;
}
for (int i = 0; i < warnings.length(); i++) {
JSONObject warning = warnings.optJSONObject(i);
if (warning == null) {
continue;
}
if (TextUtils.equals(messageId, warning.optString("requestMessageId", ""))) {
return warning;
}
}
return null;
}
private boolean hasExecutionWarningForMessage(@Nullable JSONObject payload, @Nullable String messageId) {
return findExecutionWarningForMessage(payload, messageId) != null;
}
@Nullable
private JSONObject findConversationTask(@Nullable JSONObject payload, @Nullable String messageId) {
if (TextUtils.isEmpty(messageId) || payload == null) {
return null;
}
JSONArray tasks = payload.optJSONArray("conversationTasks");
if (tasks == null) {
return null;
}
for (int i = 0; i < tasks.length(); i++) {
JSONObject task = tasks.optJSONObject(i);
if (task == null) {
continue;
}
if (TextUtils.equals(messageId, task.optString("requestMessageId", ""))) {
return task;
}
}
return null;
}
private List<JSONObject> buildMessageWarnings(JSONObject payload, String messageId) {
ArrayList<JSONObject> warnings = new ArrayList<>();
if (payload == null || TextUtils.isEmpty(messageId)) {
return warnings;
}
JSONArray rawWarnings = payload.optJSONArray("executionWarnings");
if (rawWarnings == null) {
return warnings;
}
LinkedHashMap<String, JSONObject> deduped = new LinkedHashMap<>();
for (int i = 0; i < rawWarnings.length(); i++) {
JSONObject warning = rawWarnings.optJSONObject(i);
if (warning == null) {
continue;
}
if (!TextUtils.equals(messageId, warning.optString("requestMessageId", ""))) {
continue;
}
String key = warning.optString("title", "")
+ "::"
+ warning.optString("summary", "")
+ "::"
+ warning.optString("taskId", "")
+ "::"
+ warning.optString("sessionId", "");
JSONObject existing = deduped.get(key);
if (existing == null) {
JSONObject grouped = copyJson(warning);
try {
grouped.put("count", 1);
} catch (Exception ignored) {
// Keep the original warning when count cannot be added.
}
deduped.put(key, grouped);
continue;
}
try {
existing.put("count", existing.optInt("count", 1) + 1);
} catch (Exception ignored) {
// Ignore invalid count updates and keep the original entry.
}
}
warnings.addAll(deduped.values());
return warnings;
}
private String buildStatusFingerprint(String messageId, JSONObject payload) {
JSONObject conversationTask = findConversationTask(payload, messageId);
List<JSONObject> messageWarnings = buildMessageWarnings(payload, messageId);
StringBuilder fingerprint = new StringBuilder();
if (conversationTask != null) {
fingerprint.append(conversationTask.optString("taskId", ""))
.append('|')
.append(conversationTask.optString("status", ""))
.append('|')
.append(conversationTask.optString("sessionId", ""))
.append('|')
.append(conversationTask.optString("requestId", ""));
}
fingerprint.append("::");
for (JSONObject warning : messageWarnings) {
if (warning == null) {
continue;
}
fingerprint.append(warning.optString("title", ""))
.append('|')
.append(warning.optString("summary", ""))
.append('|')
.append(warning.optString("taskId", ""))
.append('|')
.append(warning.optString("sessionId", ""))
.append('|')
.append(warning.optInt("count", 1))
.append("||");
}
return fingerprint.toString();
}
private boolean hasSameExecutionWarningForMessage(
@Nullable JSONObject currentPayload,
@Nullable JSONObject nextPayload,
@Nullable String messageId
) {
if (TextUtils.isEmpty(messageId)) {
return true;
}
String currentFingerprint = buildStatusFingerprint(messageId, currentPayload);
String nextFingerprint = buildStatusFingerprint(messageId, nextPayload);
return TextUtils.equals(currentFingerprint, nextFingerprint);
}
private JSONObject copyJson(@Nullable JSONObject source) {
if (source == null) {
return new JSONObject();

View File

@@ -0,0 +1,77 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.json.JSONObject;
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 BossApiClientTest {
@Test
public void buildProjectAgentControlsPayload_omitsAdvancedOverridesWhenAllAreNull() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
"gpt-5.4-mini",
null,
null,
null,
null,
null,
false
);
assertEquals("gpt-5.4-mini", payload.getString("modelOverride"));
assertTrue(payload.has("reasoningEffortOverride"));
assertFalse(payload.has("fastModelOverride"));
assertFalse(payload.has("fastReasoningEffortOverride"));
assertFalse(payload.has("smartModelOverride"));
assertFalse(payload.has("smartReasoningEffortOverride"));
}
@Test
public void buildProjectAgentControlsPayload_includesAdvancedOverridesWhenPresent() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
null,
null,
"gpt-5.4-mini",
null,
"gpt-5.4",
"high",
false
);
assertTrue(payload.has("fastModelOverride"));
assertEquals("gpt-5.4-mini", payload.getString("fastModelOverride"));
assertTrue(payload.has("smartModelOverride"));
assertEquals("gpt-5.4", payload.getString("smartModelOverride"));
assertTrue(payload.has("smartReasoningEffortOverride"));
assertEquals("high", payload.getString("smartReasoningEffortOverride"));
}
@Test
public void buildProjectAgentControlsPayload_includesAdvancedNullsWhenExplicitlyRequested() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
"gpt-5.4-mini",
null,
null,
null,
null,
null,
true
);
assertTrue(payload.has("fastModelOverride"));
assertTrue(payload.isNull("fastModelOverride"));
assertTrue(payload.has("fastReasoningEffortOverride"));
assertTrue(payload.isNull("fastReasoningEffortOverride"));
assertTrue(payload.has("smartModelOverride"));
assertTrue(payload.isNull("smartModelOverride"));
assertTrue(payload.has("smartReasoningEffortOverride"));
assertTrue(payload.isNull("smartReasoningEffortOverride"));
}
}

View File

@@ -21,6 +21,13 @@ public class BossUiRootSurfaceTest {
@Test
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
ReflectionHelpers.setField(
activity,
@@ -32,7 +39,7 @@ public class BossUiRootSurfaceTest {
);
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
LinearLayout content = activity.findViewById(R.id.screen_content);
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount());
View header = content.getChildAt(0);

View File

@@ -24,6 +24,8 @@ import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ConversationFolderActivityTest {
@@ -145,7 +147,7 @@ public class ConversationFolderActivityTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
assertEquals(1, activity.reloadCount);
}

View File

@@ -3,10 +3,13 @@ package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@@ -18,18 +21,27 @@ public class MainActivityConversationAutoRefreshTest {
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup().resume();
MainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("restore_token", "test-restore-token")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
Shadows.shadowOf(activity.getMainLooper()).idle();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
controller.pause();

View File

@@ -15,15 +15,35 @@ import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityRealtimeTest {
private static void showConversationTab(MainActivity activity) {
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("restore_token", "test-restore-token")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
}
private static void flushRealtimeDebounce(MainActivity activity) {
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
}
@Test
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -32,7 +52,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -42,7 +62,8 @@ public class MainActivityRealtimeTest {
@Test
public void devicesRealtimeEventDoesNotRefreshConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -51,7 +72,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -60,7 +81,8 @@ public class MainActivityRealtimeTest {
@Test
public void blankProjectIdConversationEventDoesNotRefreshVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -69,7 +91,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("conversation.updated", new JSONObject())
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.conversationRefreshCount);
}
@@ -77,7 +99,8 @@ public class MainActivityRealtimeTest {
@Test
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -86,7 +109,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("conversation.updated", new JSONObject().put("deviceId", "mac-studio"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -95,7 +118,8 @@ public class MainActivityRealtimeTest {
@Test
public void contextIndicatorEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -107,16 +131,17 @@ public class MainActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@Test
public void contextIndicatorSnapshotWithoutProjectIdRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -128,16 +153,17 @@ public class MainActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@Test
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -160,22 +186,24 @@ public class MainActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(2, activity.conversationRefreshCount);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@Test
public void devicesRealtimeEventRefreshesVisibleDevicesTabOnly() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -184,7 +212,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(1, activity.deviceRefreshCount);
@@ -194,13 +222,15 @@ public class MainActivityRealtimeTest {
@Test
public void otaRealtimeEventRefreshesVisibleMeTabOnly() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -209,7 +239,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("ota.updated", new JSONObject())
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -219,7 +249,8 @@ public class MainActivityRealtimeTest {
@Test
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true);
ReflectionHelpers.callInstanceMethod(
@@ -230,7 +261,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
ReflectionHelpers.callInstanceMethod(
activity,

View File

@@ -24,6 +24,10 @@ import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityRealtimeTest {
private static void flushRealtimeDebounce(ProjectDetailActivity activity) {
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
}
@Test
public void matchingProjectMessageEventTriggersReload() throws Exception {
Intent intent = new Intent()
@@ -43,9 +47,11 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.renderCount);
}
@Test
@@ -67,7 +73,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -91,7 +97,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -129,9 +135,11 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(2, activity.reloadCount);
assertEquals(1, activity.reloadCount);
assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.renderCount);
}
@Test
@@ -156,9 +164,11 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.renderCount);
}
@Test
@@ -194,9 +204,11 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.renderCount);
}
@Test
@@ -220,7 +232,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertTrue(activity.awaitFirstLoadStarted());
ReflectionHelpers.callInstanceMethod(
@@ -239,7 +251,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.renderCount);
@@ -315,6 +327,11 @@ public class ProjectDetailActivityRealtimeTest {
);
}
@Override
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
return loadProjectSnapshotForRefresh();
}
@Override
void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) {
renderCount += 1;

View File

@@ -714,9 +714,9 @@ public class ProjectDetailActivityUiTest {
}
@Override
protected void enqueueReplyWaitPoll(String baselineMessageId, boolean includeDispatchPlans) {
protected void enqueueReplyWaitPoll(ProjectChatUiState.ReplyWaitSpec waitSpec, boolean includeDispatchPlans) {
replyWaitPollCount += 1;
lastReplyWaitBaselineMessageId = baselineMessageId;
lastReplyWaitBaselineMessageId = waitSpec == null ? null : waitSpec.baselineMessageId;
lastReplyWaitIncludeDispatchPlans = includeDispatchPlans;
}
}

View File

@@ -23,6 +23,7 @@
6. `docs/architecture/wechat_project_conversation_mapping_cn.md`
7. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
9. `docs/superpowers/specs/2026-04-16-master-agent-fast-path-design.md`
## 3. 当前有效实现边界

View File

@@ -185,6 +185,11 @@
- 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行
- 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
- 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime可用于本地和服务器验证 `ClawBackendAdapter`
- 当前已最小接入 `Hermes Runtime`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行
- 如果历史 `backendOverride=hermes-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
- 当前仓库自带 `scripts/hermes-runtime-smoke.mjs`,可用于本地和服务器验证 `Hermes Runtime`
- 当前 `master-agent` 会话已支持显式选择 `hermes-runtime`
- 当前普通单线程会话也已支持显式保存 `backendOverride=hermes-runtime`;未设置 override 时仍走原有目标设备本地线程回复链,显式设置后服务端会异步调用 `Hermes Runtime` 并把结果回写到原线程消息流
- 当前已最小接入 `OmxTeamBackendAdapter`但默认关闭Web 群聊详情页和原生群资料页已经可以在 `Boss Native``OMX Team` 间切换编排后端OMX 不可用时会自动回退到默认后端并返回明确原因
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter``dispatch_execution` JSON 协议
@@ -200,6 +205,19 @@
- `heartbeat / thread reply` 平时优先写轻量进展事件
- 首次理解、状态变薄、长时间未刷新或主 Agent 真正接手时,才补排隐藏全量理解任务
### 3.1.3 主 Agent 任务级模型策略
- 当前任务账本 `masterAgentTasks` 已支持:
- `executionModel`
- `executionReasoningEffort`
- 当前用途:
-`master-agent` 会话上的 `smart*` 策略真正下发给深度任务,而不是只停留在设置页
-`group_dispatch_plan / device_import_resolution / attachment_analysis` 这类深度任务可以和普通聊天使用不同模型
- 当前执行链:
- 服务端排队深度任务时写入 `executionModel / executionReasoningEffort`
- `local-agent/codex-task-runner.mjs` 执行时优先使用任务级 `executionModel`
- `executionReasoningEffort` 当前已落到任务账本,供后续继续扩到更多 Runtime / API 执行器
### 3.2 认证相关
#### `POST /api/auth/send-code`
@@ -383,6 +401,8 @@
- `kind`: `text | voice_intent | image_intent | video_intent`
- 当前行为:
- 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务
- 如果该普通单线程项目当前显式设置了 `backendOverride=hermes-runtime`,这条 `conversation_reply` 会改挂到 `master-agent-hermes / hermes-runtime`
- 如果普通单线程项目没有显式设置 `backendOverride`,仍保持原有 `local-agent -> codex exec resume` 路径不变
- 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用
- `projectId=master-agent``kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本
- 当前主链路优先走 `Master Codex Node``task queue -> local-agent -> codex exec -> complete`
@@ -394,19 +414,21 @@
#### `GET /api/v1/projects/[projectId]/agent-controls`
- 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride / backendOverride`
- 用途:读取当前对话级别的模型、推理、后端与接管控制
- 当前约束:
- 当前只支持 `projectId=master-agent`
- `projectId=master-agent` 时支持 `modelOverride / reasoningEffortOverride / fastModelOverride / fastReasoningEffortOverride / smartModelOverride / smartReasoningEffortOverride / promptOverride / backendOverride / globalTakeoverEnabled`
- 普通单线程项目当前只会返回 `takeoverEnabled / backendOverride` 这类会话级覆盖
- 未配置时返回 `controls: null`
#### `POST /api/v1/projects/[projectId]/agent-controls`
- 用途:更新当前对话级别的 `modelOverride / reasoningEffortOverride / promptOverride / backendOverride`
- 用途:更新当前对话级别的模型、推理、后端与接管控制
- 当前约束:
- 当前支持 `projectId=master-agent`
- `highest_admin` 可写
- `backendOverride` 当前仅支持 `claw-runtime`
- 只有在 `Claw Runtime` 可用性探测通过时才允许保存 `claw-runtime`
- `master-agent` 会话当前支持 `modelOverride / reasoningEffortOverride / fastModelOverride / fastReasoningEffortOverride / smartModelOverride / smartReasoningEffortOverride / promptOverride / backendOverride / globalTakeoverEnabled`
- 主 Agent 普通聊天回复会按 `fast*` 默认策略解析模型与推理强度,深度任务保留 `smart*` 默认策略入口;显式 `modelOverride / reasoningEffortOverride` 始终优先于 fast/smart 策略
- 普通单线程会话当前只支持 `takeoverEnabled / backendOverride`
- 普通单线程会话的 `backendOverride` 当前只允许 `hermes-runtime`,不开放 `claw-runtime`
- 只有在对应 Runtime 可用性探测通过时才允许保存对应覆盖
- 显式传 `null` 或空字符串表示清空覆盖;省略字段表示保留原值
#### `GET /api/v1/projects/[projectId]/orchestration-backend`

View File

@@ -31,6 +31,10 @@
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime`
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因
- 当前仓库已自带 `scripts/claw-runtime-smoke.mjs` 作为本地 smoke runtime在没有真实 `claw-code` 可执行文件时,可先用 `BOSS_CLAW_COMMAND=node``BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs` 验证整条链
- 当前 `hermes-agent` 已以最小 `Hermes Runtime` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_HERMES_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `hermes-runtime`
- 如果历史上已经保存过 `backendOverride=hermes-runtime`,但当前 `Hermes Runtime` 不可用,运行时会自动回退到默认后端,并在 Web 前台给出明确原因
- 当前仓库已自带 `scripts/hermes-runtime-smoke.mjs` 作为本地 Hermes smoke runtime在没有真实 `hermes` 可执行文件时,可先用 `BOSS_HERMES_COMMAND=node``BOSS_HERMES_ARGS=scripts/hermes-runtime-smoke.mjs` 验证整条链
- 当前普通单线程会话也已支持对话级 `backendOverride=hermes-runtime`;未显式设置时仍走原有 `local-agent -> codex exec resume` 回复链,显式设置后会由服务端异步调用 `Hermes Runtime` 执行普通线程回复,并直接通过 `completeMasterAgentTask` 回写到原线程会话
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换OMX 不可用时会自动回退到默认后端并明确提示原因
- 当前仓库已自带 `scripts/omx-team-smoke.mjs` 作为本地 OMX smoke runtime在没有真实 `oh-my-codex` 可执行文件时,可先用 `BOSS_OMX_COMMAND=node``BOSS_OMX_ARGS=scripts/omx-team-smoke.mjs` 验证 `dispatch_execution` 的真实执行 contract
- 当前主 Agent 对活跃线程的理解已经升级成“线程状态文档 + 最近进展事件 + 关键时刻深拉”:`projectUnderstanding` 不再是唯一输入,`threadStatusDocuments / threadProgressEvents` 已进入主 Agent prompt 主链
@@ -158,8 +162,9 @@ cd /Users/kris/code/boss
- `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
- 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt
- 当前对话级 `agentControls` 也已支持 `backendOverride``master-agent` 会话可在 `Claw Runtime` 可用时显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端;不可用时保存接口会直接拒绝,并返回人类可读原因
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride` 强制覆盖,也支持 `fastModelOverride / fastReasoningEffortOverride / smartModelOverride / smartReasoningEffortOverride` 这组策略默认值;主 Agent 普通对话默认按 fast 档选模型,深度任务可按 smart 档选模型,手动强制覆盖仍然优先级最高
- 当前 `group_dispatch_plan / device_import_resolution / attachment_analysis` 三类深度任务已经会把 `smart*` 策略下发到任务队列,并随任务持久化 `executionModel / executionReasoningEffort`local-agent 执行这类任务时会优先吃任务级模型,不再只依赖本机固定默认模型
- 当前对话级 `agentControls` 也已支持 `backendOverride``master-agent` 会话可在 `Claw Runtime``Hermes Runtime` 可用时显式选择 `claw-runtime / hermes-runtime`,普通单线程会话当前只开放 `hermes-runtime`;不可用时保存接口会直接拒绝,并返回人类可读原因
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
- `POST /api/v1/app-logs``POST /api/v1/devices/[deviceId]/skills``POST /api/v1/workers/[workerId]/thread-context` 当前都要求有效设备 token 或匹配登录会话

View File

@@ -0,0 +1,472 @@
# Hermes Runtime 接入与运维指南
更新时间:`2026-04-14`
## 1. 文档目标
这份文档只回答一个问题:
- Boss 当前已经接入的 `hermes-runtime`,本地和服务器应该怎么配、怎么验、怎么运维
它面向两类人:
- 开发者:想先在本机把 Hermes 链路跑通
- 运维:想把 Hermes 作为 Boss 的一个可选执行后端部署到稳定环境
这不是长期架构文档。长期方向请看:
- `docs/architecture/hermes_对_boss_主agent长期融合评估_cn.md`
## 2. 当前实现边界
当前 Boss 对 Hermes 的接入边界很明确:
- Hermes 现在是 `ExecutionBackend` 下的一个可选后端,`backendId=hermes-runtime`
- 当前接入方式是外部 CLI 调用,不直接 import Hermes Python 代码
- 当前支持的请求类型只有:
- `master_agent_reply`
- `thread_reply`
- 当前不支持:
- `dispatch_execution`
- 群聊编排
- 会话级 session resume
- Honcho / ACP / API server 深融合
Boss 当前对 Hermes 的固定调用形态是:
```text
<command> <prefix args> chat -q <executionPrompt> -Q --source <sourceTag>
```
可选追加:
- `-m <model>`
- `-t <toolsets>`
- `-s <skills>`
Boss 会自动剥掉 Hermes quiet 输出末尾的:
```text
session_id: ...
```
只把正文写回 Boss 对话账本。
## 3. 环境变量说明
当前 Hermes 接入读取以下环境变量。
### 3.1 核心开关
#### `BOSS_HERMES_ENABLED`
- 含义:是否启用 Hermes Runtime
- 可选值:`true | false`
- 默认值:`false`
只有为 `true`Boss 才会把 Hermes 纳入可选后端探测。
#### `BOSS_HERMES_COMMAND`
- 含义:启动 Hermes 的主命令
- 默认值:`hermes`
常见写法:
- `hermes`
- `uv`
- `python3`
- `node`
说明:
- 如果你已经把 Hermes 安装成全局命令,直接用 `hermes`
- 如果你用 `uv run ...` 启动 Hermes就把命令写成 `uv`
- 如果你通过脚本入口启动,就把命令写成对应运行时,例如 `python3``node`
#### `BOSS_HERMES_ARGS`
- 含义:追加在 `BOSS_HERMES_COMMAND` 后、`chat -q ...` 前面的前缀参数
- 默认值:空
例子:
```bash
BOSS_HERMES_ARGS="run --directory /opt/hermes-agent hermes"
```
最终实际执行会变成:
```bash
uv run --directory /opt/hermes-agent hermes chat -q "..." -Q --source tool
```
如果你是脚本入口,也可以这样写:
```bash
BOSS_HERMES_COMMAND=python3
BOSS_HERMES_ARGS="/opt/hermes-agent/cli.py"
```
### 3.2 运行目录与超时
#### `BOSS_HERMES_WORKDIR`
- 含义Hermes 运行时工作目录
- 默认值:未设置
推荐:
- 本地开发:设置成 Boss 工作区或 Hermes 工作区
- 服务器:显式设置,不依赖 `process.cwd()`
#### `BOSS_HERMES_TIMEOUT_MS`
- 含义:单次 Hermes CLI 调用的超时毫秒数
- 默认值:`120000`
建议:
- 本地 smoke`5000``15000`
- 日常开发:`30000``120000`
- 生产服务:不要无限拉长,先用 `60000``120000`
### 3.3 模型、toolsets、skills
#### `BOSS_HERMES_DEFAULT_MODEL`
- 含义当前对话未显式覆盖模型时Boss 传给 Hermes 的默认模型名
- 默认值:无
#### `BOSS_HERMES_TOOLSETS`
- 含义:逗号分隔的 Hermes toolsets
- 示例:`web,terminal`
Boss 会转成:
```bash
-t web,terminal
```
#### `BOSS_HERMES_SKILLS`
- 含义:逗号分隔的 Hermes skills
- 示例:`boss-dev,github`
Boss 会转成:
```bash
-s boss-dev,github
```
#### `BOSS_HERMES_SOURCE_TAG`
- 含义:传给 Hermes 的 `--source` 标记
- 默认值:`tool`
通常保持默认即可。
## 4. 可用性探测规则
Boss 当前对 Hermes 的可用性探测是保守模式。
它会检查:
1. `BOSS_HERMES_ENABLED=true`
2. `BOSS_HERMES_COMMAND` 可执行
3. 如果设置了 `BOSS_HERMES_WORKDIR`,目录必须存在
4. 如果命令是脚本运行时:
- `node`
- `tsx`
- `bun`
- `deno`
- `python`
- `python3`
那么 `BOSS_HERMES_ARGS` 的第一个参数会被视为脚本路径,并检查该脚本是否存在
因此有两个实践建议:
- 用全局 `hermes` 命令时,最省心
- 用脚本入口时,第一段参数一定要是真实脚本路径,不要把不存在的包装路径塞进去
## 5. 本地 Smoke 验证
这是当前最推荐的第一步,因为它不依赖真实 Hermes 环境。
### 5.1 环境变量
在 Boss 工作区里设置:
```bash
export BOSS_HERMES_ENABLED=true
export BOSS_HERMES_COMMAND=node
export BOSS_HERMES_ARGS=scripts/hermes-runtime-smoke.mjs
export BOSS_HERMES_WORKDIR=/Users/kris/code/boss
export BOSS_HERMES_TIMEOUT_MS=5000
```
### 5.2 启动 Boss
```bash
cd /Users/kris/code/boss
npm run build
npm start
```
### 5.3 验证方式
验证点有三层:
1. `GET /api/v1/projects/master-agent/agent-controls`
- 应该能看到 `hermesAvailability.selectable=true`
2.`master-agent` 会话里保存 `backendOverride=hermes-runtime`
3.`master-agent` 或普通单线程会话发消息
- 应该看到任务进入 `queued`
- 随后回写 smoke 内容
如果只想测后端契约,不测前端,也可以直接跑测试:
```bash
npx tsx --test tests/hermes-backend-config.test.ts tests/hermes-runner.test.ts tests/hermes-backend.test.ts
```
## 6. 真实 Hermes CLI 接入
当前推荐两种方式。
### 6.1 方式 A系统里已经有 `hermes` 命令
适合:
- 已经通过官方推荐方式安装 Hermes
- `PATH` 里直接能找到 `hermes`
示例:
```bash
export BOSS_HERMES_ENABLED=true
export BOSS_HERMES_COMMAND=hermes
export BOSS_HERMES_WORKDIR=/opt/hermes-workspace
export BOSS_HERMES_TIMEOUT_MS=60000
export BOSS_HERMES_DEFAULT_MODEL=gpt-5.4
export BOSS_HERMES_TOOLSETS=web,terminal
```
优点:
- 配置最简单
- 可用性探测最稳定
缺点:
- 依赖系统环境已经正确安装 Hermes
### 6.2 方式 B通过 `uv run` 进入 Hermes 仓库
适合:
- 不想做全局安装
- 希望固定某个 Hermes checkout 或虚拟环境
示例:
```bash
export BOSS_HERMES_ENABLED=true
export BOSS_HERMES_COMMAND=uv
export BOSS_HERMES_ARGS="run --directory /opt/hermes-agent hermes"
export BOSS_HERMES_WORKDIR=/opt/hermes-workspace
export BOSS_HERMES_TIMEOUT_MS=60000
export BOSS_HERMES_DEFAULT_MODEL=gpt-5.4
```
优点:
- 上游版本更可控
- 不污染全局命令
缺点:
- `uv` 自身必须已安装
- `BOSS_HERMES_ARGS` 更容易配错
## 7. 服务器侧推荐做法
Boss 当前服务器是:
- 主机:`106.53.170.158`
- 代码路径:`/opt/boss`
如果要在服务器上给 Boss 打开 Hermes推荐这样做。
### 7.1 目录分离
不要把 Hermes 仓库直接塞进 Boss 仓库里。
推荐:
- Boss 代码:`/opt/boss`
- Hermes 代码:`/opt/hermes-agent`
- Hermes 工作目录:`/opt/hermes-workspace`
原因:
- 便于各自升级
- 避免把 Boss 构建、部署、权限模型和 Hermes 混在一起
### 7.2 运行用户一致
Boss 当前服务是 `ubuntu` 用户启动。
Hermes 也尽量用同一运行用户准备环境,避免:
- `boss-web.service` 能启动 Boss但找不到 Hermes 环境
- `uv` 或虚拟环境只在 root 下可用
- 工作目录权限错乱
### 7.3 systemd 环境显式注入
如果是服务器长期运行,不要只靠 shell profile。
推荐把以下变量写进 `boss-web.service` 的环境或 `.env.server`
```bash
BOSS_HERMES_ENABLED=true
BOSS_HERMES_COMMAND=uv
BOSS_HERMES_ARGS=run --directory /opt/hermes-agent hermes
BOSS_HERMES_WORKDIR=/opt/hermes-workspace
BOSS_HERMES_TIMEOUT_MS=60000
```
### 7.4 先做 smoke再切真实 runtime
推荐顺序:
1. 先用 `scripts/hermes-runtime-smoke.mjs` 验证 Boss 侧链路
2. 再切到真实 Hermes CLI
3. 再让 `master-agent` 选择 `hermes-runtime`
4. 最后再放开普通线程的小范围试用
不要一上来就在服务器上直接切真实 Hermes然后把问题混在
- Boss 路由
- 环境变量
- Hermes 安装
- 模型配置
- toolsets
- 权限问题
## 8. 不建议的做法
当前阶段,明确不建议以下做法。
### 8.1 不建议把 Hermes 当 Boss 的主运行真相
不要把这些直接交给 Hermes
- 会话账本
- 群聊成员真相
- 审批状态
- 设备导入状态
- 项目/线程业务归属
这些必须继续留在 Boss。
### 8.2 不建议直接依赖 Hermes SQLite 或 ACP 会话作为 Boss 会话真相
原因:
- Boss 的项目/线程/群聊模型和 Hermes session 模型不是一回事
- 当前 Boss 还没有 session binding 抽象
- ACP 会话也不是 Boss 的业务真相
### 8.3 不建议第一批就打开高风险 toolsets
如果只是验证 Hermes 接入,不要一开始就把所有工具都打开。
先用最小集合:
- 可只开基础聊天
- 如果确实要工具,先开低风险 toolsets
原因:
- 当前 Boss 还没有完整的 `PermissionPolicy -> runtime policy` 映射
- 先验证后端链路,再扩大能力面更稳
### 8.4 不建议把 Hermes 和 local-agent 混成同一执行器
当前普通线程默认路径仍然是:
```text
Boss -> queue conversation_reply -> local-agent -> codex exec resume -> complete
```
只有显式设置 `backendOverride=hermes-runtime` 时,才会切到 Hermes 占位任务路径。
不要把这两条链未经抽象就揉成一个执行器,否则后面很难排查问题。
## 9. 推荐排障顺序
如果 `Hermes Runtime` 在前台不可选,按这个顺序查。
### 9.1 先查可用性
看接口返回:
- `GET /api/v1/projects/master-agent/agent-controls`
- `GET /api/v1/projects/master-agent/prompt-profile`
重点字段:
- `hermesAvailability.status`
- `hermesAvailability.reason`
- `hermesAvailability.reasonLabel`
### 9.2 再查命令本身能不能跑
例如:
```bash
hermes --help
```
或:
```bash
uv run --directory /opt/hermes-agent hermes --help
```
### 9.3 再查 Boss 最终会拼成什么命令
当前 Boss 固定会在你的前缀后补:
```bash
chat -q "<executionPrompt>" -Q --source tool
```
所以要确认你的命令前缀真的兼容这种拼法。
### 9.4 最后查任务回写
如果前台已经能选 Hermes但消息没有回流
-`masterAgentTasks`
-`/api/v1/master-agent/tasks/[taskId]/complete`
- 查 Boss 日志里是否是 Hermes 执行失败后回退到别的后端
## 10. 当前推荐结论
如果你现在要让 Boss 用 Hermes推荐顺序是
1. 先用 `scripts/hermes-runtime-smoke.mjs` 验证 Boss 契约
2. 再接真实 Hermes CLI
3. 先只给 `master-agent` 打开
4. 再按项目给普通单线程会话灰度打开 `backendOverride=hermes-runtime`
5. 群聊编排、session resume、Honcho、ACP、API server 后续再做
当前阶段最稳的定位仍然是:
- Boss 负责产品真相
- Hermes 负责可替换执行 runtime

View File

@@ -0,0 +1,772 @@
# Hermes 对 Boss 主 Agent / 业务流的长期融合评估
更新时间:`2026-04-14`
## 1. 文档目标
这份文档回答四个问题:
1. Hermes 对 Boss 主 Agent 的长期参考价值是什么
2. 哪些能力值得抽象进 Boss 内核,哪些不要借
3. 第二阶段与第三阶段建议路线
4. 风险、边界和升级策略
这不是一份“把 Hermes 整体接进 Boss”的实施说明而是一份长期架构判断文档。结论基于以下输入
- Boss 当前运行真相与既有融合策略:
- `docs/architecture/boss_external_runtime_fusion_strategy_cn.md`
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
- `docs/architecture/api_and_service_inventory_cn.md`
- `docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md`
- Boss 当前 Hermes MVP 实现:
- `src/lib/execution/backends/hermes-config.ts`
- `src/lib/execution/backends/hermes-runner.ts`
- `src/lib/execution/backends/hermes-backend.ts`
- `src/lib/execution/backend-selector.ts`
- 对应 Web 控制与测试文件
- Hermes 官方公开文档:
- README: `https://raw.githubusercontent.com/NousResearch/hermes-agent/main/README.md`
- Quickstart: `https://hermes-agent.nousresearch.com/docs/getting-started/quickstart/`
- Architecture: `https://hermes-agent.nousresearch.com/docs/developer-guide/architecture/`
- Session Storage: `https://hermes-agent.nousresearch.com/docs/developer-guide/session-storage/`
- Tools & Toolsets: `https://hermes-agent.nousresearch.com/docs/user-guide/features/tools/`
- ACP: `https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/`
- API Server: `https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server/`
- Security: `https://hermes-agent.nousresearch.com/docs/user-guide/security/`
- Honcho Memory: `https://hermes-agent.nousresearch.com/docs/user-guide/features/honcho/`
## 2. 证据边界
本次评估有一个必须显式写清楚的边界:
- 用户指定优先阅读的 `/tmp/hermes-agent/README.md``/tmp/hermes-agent/docs/acp-setup.md``/tmp/hermes-agent/docs/honcho-integration-spec.md` 在当前工作机上不存在,未能读取。
- 因此,本文对 Hermes 的判断以 Boss 仓库内已落地的 Hermes MVP 实现,加上 Hermes 官方公开文档为主。
- 这意味着本文对“上游最近几次提交中的未发布细节”不做强判断,只对已经稳定公开、且确实会影响 Boss 长期架构边界的能力做判断。
换句话说,这份文档适合指导 Boss 第二阶段、第三阶段的融合方向,但不应被当成对 Hermes 私有或未发布能力的最终定论。
## 3. 当前状态判断
### 3.1 Boss 侧当前已经做了什么
Boss 已经不是“讨论要不要接 Hermes”的阶段而是已经完成了一个最小接入
-`src/lib/execution/` 下已经形成稳定执行底座
- Hermes 当前以 `hermes-runtime` 的形式进入 `ExecutionBackend`
- 接入范围目前只覆盖 `master-agent` 的执行后端选择
- 运行方式是外部命令调用,不 import Hermes Python 代码
- 当前契约本质上仍是 one-shot
- Boss 组装 prompt
- 调用 Hermes CLI
- 读取 stdout
- 去掉 `session_id`
- 将正文作为回复写回 Boss
这说明 Boss 当前的判断是对的:先把 Hermes 放在执行层适配器位置,而不是把它当成 Boss 产品层替代物。
### 3.2 Hermes 上游真正强在哪里
基于官方文档Hermes 的强项不是 Boss 已经做得好的产品域,而是以下几层:
- 成熟 agent loop
- Hermes 官方架构文档明确把核心对话循环、工具注册、provider 解析、session 存储、gateway、ACP 都做成了一套统一 runtime。
- 丰富的工具与 toolset 体系
- 官方架构文档写明 Hermes 当前注册了 47 个工具、19 个 toolsetterminal 工具支持 6 种 backend官方工具文档同时说明这些能力可以按平台启停并支持 MCP 动态扩展。
- 持久 session 与可检索历史
- 官方 session storage 文档说明 Hermes 用 SQLite + FTS5 持久化会话元数据、消息历史和 lineage。
- 多入口但共享同一运行时
- CLI、gateway、ACP、API server 共用 provider / tool / state 基础设施;其中 API server 还可以把 Hermes 暴露成 OpenAI-compatible HTTP endpoint。
- 安全和审批模型
- 官方 security 文档明确有危险命令审批、容器隔离、MCP 凭据过滤、context file 扫描。
- 长期记忆体系
- 官方 Honcho 文档说明 Hermes 不只有本地 memory 文件,还有 memory provider pluginHoncho 以插件形式提供更深的用户建模和跨 session 结论归纳。
- 子代理与并行执行
- README 和工具文档都把 delegation、code execution、cron 作为一等能力。
因此Hermes 对 Boss 的长期价值,主要不是“多一个能跑的 CLI”而是“提供一套已经在执行层、会话层、工具层、权限层成型的 agent runtime 参考系”。
## 4. 总结论
一句话结论:
- Hermes 对 Boss 主 Agent 的长期参考价值很高
- 但它更适合作为 Boss 的“执行内核候选 + 运行时能力参考系”
- 不适合作为 Boss 的“产品骨架、业务状态源或编排真相”
进一步展开:
### 4.1 Hermes 的长期参考价值是什么
Hermes 对 Boss 的长期价值主要有五层。
#### 第一层:验证 Boss 的执行抽象方向是对的
Boss 已经抽出了 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend`。Hermes 官方架构恰好证明了这条路是对的:
- provider 解析可以独立
- tool registry 可以独立
- session persistence 可以独立
- UI / gateway / ACP 只是不同入口,不应倒逼业务层重塑
这意味着 Boss 继续把“业务层稳定、运行时可替换”做深,是有上游现实参照的。
#### 第二层:给 Boss 主 Agent 提供更强的执行上限
Boss 当前默认主链仍偏“Boss 产品逻辑 + 外部执行通道”。Hermes 提供的是一个更完整的 agent runtime
- 工具选择能力更强
- 文件 / 终端 / 浏览器 / MCP / delegation 能力更全
- 记忆、session search、skills、approval 都在一个 runtime 内
对 Boss 主 Agent 来说这意味着未来可以把复杂任务逐步从“Boss 先做大量业务编排,再找后端执行一句 prompt”提升到“Boss 给出业务边界Hermes 在边界内自主完成更多工作”。
#### 第三层:帮助 Boss 定义“会话级执行”的中间层
Boss 当前 Hermes MVP 还是 one-shot但 Hermes 官方文档显示它天然具备:
- session 持久化
- lineage
- ACP 会话管理
- API server
- gateway 统一 routing
这对 Boss 很重要,因为 Boss 以后一定会遇到“主 Agent 到底是一次性回答,还是持续工作中的 agent session”这个问题。Hermes 不能直接成为 Boss 的 session 真相,但它是一个很好的 session 能力参考实现。需要额外注意的是,官方 ACP 文档明确说明 ACP 的 `list/load/resume/fork` 只在当前 ACP server 进程存活期间有效,这再次证明它更适合作为执行入口参考,而不是 Boss 的长期会话真相。
#### 第四层:为 Boss 的工具能力治理提供成熟参照
Hermes 的 toolset 分组、审批、安全边界、terminal backend 分层都很成熟。Boss 不一定要照搬工具名和实现,但值得吸收它的治理模型:
- 工具不是平铺权限,而是按风险级别和运行场景分层
- 本地、容器、SSH、云执行是同一工具能力的不同 backend
- 审批与持久 allowlist 是 runtime 级能力,而不是前台零散开关
这正是 Boss 后续把主 Agent 从“能说”升级到“能稳定干活”时最需要的部分。
#### 第五层:提供“可升级外部 runtime”的实际样板
Hermes 不只是功能点集合它还是一个活跃上游。Boss 如果能把 Hermes 视作“外部 runtime 插件族”之一,而不是一次性集成项目,那么 Boss 未来可以用相同方法吸收:
- Hermes
- Claw
- OMX
- 其他 ACP / API / CLI agent runtime
这会让 Boss 逐步形成自己的执行生态,不再被某一个上游实现绑死。
## 5. 哪些能力值得抽象进 Boss 内核
这里的原则不是“哪个能力厉害就搬哪个”,而是:
- 只抽象 Boss 必须长期拥有主导权的层
- 只抽象对多个 runtime 都成立的层
- 只抽象不会把 Boss 业务真相外包给 Hermes 的层
### 5.1 值得抽象进 Boss 内核的能力
#### 5.1.1 Runtime Capability Profile
Boss 现在已经有 backend selectable / availability但还不够系统。应该继续抽象成统一的 runtime capability profile例如
- 是否支持 one-shot
- 是否支持 session resume
- 是否支持 delegation
- 是否支持 toolsets
- 是否支持 structured approval
- 是否支持 remote terminal backend
- 是否支持 memory provider
- 是否支持 MCP
原因:
- 这是 Boss 做“后端选择、灰度、回退、UI 呈现”的稳定基础
- 这层应该由 Boss 控制,不应把 Hermes 的内部概念直接泄漏到前台
#### 5.1.2 Session Binding Interface
Boss 现在把 Hermes `session_id` 去掉是对的,但长期不能永远停在这里。应该抽象一层通用 session binding interface而不是 Hermes 专用字段:
- `createSession`
- `resumeSession`
- `closeSession`
- `sessionMetadata`
原因:
- 未来不止 Hermes 会有 session
- Boss 需要控制“哪个业务项目/线程绑定哪个 runtime session”
- 但 session 的生命周期必须由 Boss 业务事件驱动,而不是由 Hermes 自己的 SQLite 状态反向决定
#### 5.1.3 Tool Capability Registry
Boss 现在有 `ToolRegistry` 雏形,但后续应该升级成“工具能力注册表”,重点不是工具实现,而是能力治理:
- 能力类别
- 风险等级
- 是否需要审批
- 哪些 runtime 支持
- 哪些业务场景允许
原因:
- Hermes 的 toolsets 给了很好的上游样板
- Boss 必须把“业务权限”与“runtime 工具能力”中间再插一层自己的治理
#### 5.1.4 Permission Policy 到 Runtime Policy 的映射层
Hermes security 文档说明审批、安全扫描、allowlist 是 runtime 一等能力。Boss 也需要形成自己的 policy mapping 层:
- Boss 业务策略:
- 这个项目允许不允许写文件
- 当前群聊是否允许直接 dispatch
- 当前设备是否允许远程终端
- Runtime 执行策略:
- Hermes toolsets 开哪些
- Hermes terminal backend 用本地还是 SSH
- 需要哪些审批模式
原因:
- 这是 Boss 产品真相和 Hermes runtime 真相之间最关键的隔离层
#### 5.1.5 记忆分层接口
Boss 不应该直接吃 Hermes 的记忆文件或 Honcho 结构但应该把“Boss 业务记忆”和“外部 runtime 记忆能力”拆分成两层:
- Boss 业务记忆:
- 项目目标
- 项目记忆
- 线程状态文档
- 最近进展事件
- 用户级主 Agent 偏好
- 外部 runtime 记忆:
- Hermes local memory
- session search
- Honcho profile / context
Boss 内核需要的是统一接口,而不是固定供应商实现。
#### 5.1.6 Runtime Health / Fallback / Telemetry
Hermes 当前已经接入 availability 与 fallback但长期应升级成统一能力
- 启动健康探测
- capability 探测
- 最近失败原因
- 版本信息
- smoke 测试结果
- 自动回退规则
原因:
- 这决定 Boss 是否能安全地长期吸收外部 runtime 的升级成果
### 5.2 有条件再考虑、但现在不该内核化的能力
#### 5.2.1 Delegation / Subagent Contract
Hermes 的 delegation 很强,但 Boss 现在不该直接把“子代理体系”写死进内核。更合理的做法是:
- 第二阶段只把它视为 runtime capability
- 第三阶段再评估是否需要 Boss 自己定义统一 subagent contract
因为一旦定义过早,就容易被某个 runtime 的行为模型绑死。
#### 5.2.2 Memory Provider Plugin
Hermes 已经有 memory provider plugin 和 Honcho。Boss 长期可以参考“memory provider 可插拔”这个方向,但不建议在第二阶段就把 Boss 记忆系统插件化。当前 Boss 的项目理解、线程状态文档、进展事件还在快速演化,过早插件化会锁死模型。
## 6. 哪些能力不要借,或者只可作为外部能力存在
这里是最重要的边界。Hermes 很强,但 Boss 不能被 Hermes 反向塑形。
### 6.1 不要借来当 Boss 产品骨架的能力
#### 6.1.1 Gateway / 多平台消息入口
Hermes 有 CLI、Telegram、Discord、Slack、WhatsApp、Signal、Email、API server、ACP 等多入口能力,但 Boss 不应该把这些入口当作自己的前台路线。
原因:
- Boss 已经有 Web / Android / local-agent / 群聊 / 审批 / 设备导入等产品主链
- 如果把 Hermes gateway 当成 Boss 主入口,产品模型会被即时通讯平台语义反向塑形
判断:
- 可以作为外部执行入口参考
- 不应该成为 Boss 主业务入口
#### 6.1.2 Hermes 的 SQLite Session Store
Hermes 官方 session storage 很成熟,但 Boss 不能把它当成业务真相库。
原因:
- Boss 的真相不是“agent 对话历史”这么简单
- Boss 还有项目、线程、设备、审批、群聊、导入、记忆、账本、投影视图
- 一旦让 Hermes state.db 成为真实会话源Boss 会失去对业务生命周期的主导权
判断:
- 可以参考 schema 和 lineage 思路
- 不能作为 Boss 核心账本或主状态库
#### 6.1.3 Honcho 用户建模作为 Boss 主记忆
Honcho 的优势是跨 session、跨设备、跨 peer 的用户建模,但 Boss 不能直接拿它替代自己的主 Agent 记忆体系。
原因:
- Boss 关心的是业务记忆,不只是人格/偏好/沟通风格
- Honcho 适合“用户画像 + 深层偏好”
- Boss 更需要“项目事实、线程事实、设备事实、审批事实”
判断:
- 可以作为外部补充记忆源
- 不能成为 Boss 记忆主真相
#### 6.1.4 ACP Session Manager 语义
Hermes ACP 文档明确说 ACP 会话只在 ACP server 进程存活期间有效,`list/load/resume/fork` 也限定在该 server 生命周期内。
这对 Boss 来说只能作为编辑器集成参考,不能直接拿来做主 Agent 长期会话模型。
原因:
- Boss 会话生命周期是业务驱动,不是 editor server 驱动
- ACP 进程级 session manager 太短命
#### 6.1.5 Cron 与平台投递系统
Hermes 自带 cron 调度和任意平台投递,但 Boss 当前不应该把这部分纳入主 Agent 内核。
原因:
- Boss 当前最核心的问题是执行层、业务状态、审批和项目理解,不是调度器丰富度
- 过早接入 cron 会把“任务调度”和“业务执行”耦合
判断:
- 可作为未来运维或后台任务参考
- 不应进入第二阶段主线
### 6.2 不要照搬的设计风格
#### 6.2.1 不要把 Hermes 的工具名和 slash command 暴露成 Boss UI 概念
Boss 前台不应该出现:
- `toolsets`
- `session_search`
- `honcho_profile`
- `delegate_task`
- `allow once / allow always`
这类原生 Hermes 概念可以存在于适配层和运维界面,但不应直接成为 Boss 面向用户的产品语言。
#### 6.2.2 不要让 Hermes 的 context files 决定 Boss 提示词层级
Hermes 有自己的 context files / skills / memory 注入方式。Boss 不能因此削弱现有的:
- 管理员全局主提示词
- 用户私有主提示词
- 当前对话附加提示词
- 项目记忆
- 线程状态文档
Boss 的提示词优先级必须继续由 Boss 掌控。
## 7. 第二阶段建议路线
第二阶段的关键词不是“接更多功能”,而是“把 Hermes 从最小可用后端升级成可长期运营的受控后端”。
### 7.1 第二阶段目标
目标应收敛为三件事:
1. 把 Hermes 从 demo 级 one-shot backend 升级为受控运行时后端
2. 让 Boss 对 Hermes 的能力感知、配置、回退、观测更完整
3. 仍然不让 Hermes 进入 Boss 编排层与业务真相层
### 7.2 第二阶段建议任务
#### 7.2.1 补齐 Runtime Capability Profile
为 Hermes 增加稳定 capability 描述,而不是只暴露 `selectable / reasonLabel`
- `supportsOneShot`
- `supportsSessionResume`
- `supportsDelegation`
- `supportsToolsets`
- `supportsMemoryProvider`
- `supportsMcp`
- `supportsApprovalBridge`
- `supportsRemoteTerminal`
这样 Boss 后续前台、策略、实验、灰度都能统一处理。
#### 7.2.2 引入最小 Session Metadata 捕获,但不直接业务化
建议第二阶段开始捕获 Hermes `session_id`,但只作为内部诊断元数据,不立刻绑定业务主链:
- 记录最近一次执行返回的 `session_id`
- 记录 Hermes command/model/toolsets/skills
- 记录运行耗时、退出码、fallback 原因
不做:
- 不做 Boss 项目线程到 Hermes session 的强绑定
- 不做 resume 主链
这样能为第三阶段做准备,但不会过早扩大范围。
#### 7.2.3 做一层 Boss -> Hermes policy mapping
把 Boss 的业务约束映射成 Hermes 运行时约束:
- 项目级只读 / 可写
- 是否允许 terminal
- 是否允许 web / browser
- 是否允许 delegation
- 是否允许外部 MCP
第二阶段可以先从静态配置开始,不必一开始就做动态全量映射。
#### 7.2.4 建立 Hermes 专属 smoke / health / version 检查
当前 availability 只检查命令、cwd、脚本存在。第二阶段应该加入
- `hermes --version` 或等价 version check
- 最小真实 prompt smoke
- toolset 启动 smoke
- provider 可用性检查
- 可选的 API server / ACP 旁路探测
这会把“能选”升级成“可运营”。
#### 7.2.5 限定场景开展 A/B 对照实验
第二阶段只建议在主 Agent 的少数高价值场景做 Hermes 对照:
- 长提示词理解
- 需要文件/终端/浏览器联动的问题
- 需要多步工具执行的问题
- 需要更强自主探索的问题
不要一上来把所有主 Agent 请求默认切到 Hermes。
### 7.3 第二阶段明确不做
- 不接 `dispatch_execution`
- 不接群聊编排
- 不接设备导入审核链
- 不接 Honcho 作为 Boss 默认记忆
- 不接 ACP 成为 Boss 主前台
- 不把 Hermes API server 暴露成 Boss 对外 API 主链
## 8. 第三阶段建议路线
第三阶段的关键词是“选择性深化”,不是“全面融合”。
### 8.1 第三阶段前提
只有在第二阶段满足以下条件后,才建议进入第三阶段:
- Hermes 后端运行稳定
- fallback 和 kill switch 可靠
- Boss 已形成 capability / policy / telemetry 的统一抽象
- 已明确哪些任务在 Hermes 上明显优于当前默认后端
### 8.2 第三阶段可以做什么
#### 8.2.1 引入 Session-Aware Backend Contract
如果第二阶段观察证明 Hermes 在持续任务里明显更有优势,第三阶段可以升级为 session-aware backend
- Boss 为项目或线程保存 runtime session binding
- 支持显式 resume / compact / close
- 主 Agent 在少数项目上变成“持续运行的工作线程”
但必须坚持:
- Boss 决定何时创建、恢复、关闭
- Hermes 不直接拥有业务生命周期
#### 8.2.2 选择性开放 Delegation
第三阶段可以考虑让 Hermes delegation 进入 Boss但只作为受限能力
- 只在主 Agent 内部使用
- 只对特定项目开放
- 必须经过 Boss policy 层约束
- 子代理产物回写必须标准化
换句话说Boss 可以借 Hermes 的“多步执行能力”,但不能把 Boss 的编排主权交给 Hermes。
#### 8.2.3 引入可插拔 Memory Provider Adapter
第三阶段可以评估把 Honcho 或其他 memory provider 作为外部补充记忆源接入:
- 用于用户偏好、工作习惯、长期画像
- 不用于项目事实和审批真相
Boss 应保持“双层记忆”:
- Boss 事实记忆
- 外部推理型记忆
#### 8.2.4 评估 ACP / API Server 作为受控旁路入口
如果未来 Boss 需要编辑器直连、桌面 IDE 集成或本地私有 agent service第三阶段可以评估
- ACP 作为开发态集成通道
- Hermes API server 作为受控本地后端
但必须保持:
- Boss 是业务真相层
- ACP / API server 只是执行入口
### 8.3 第三阶段仍然不要做什么
- 不让 Hermes 替代 Boss 群聊编排
- 不让 Hermes 替代 Boss 审批流
- 不让 Hermes 替代 Boss 设备导入逻辑
- 不让 Hermes state.db 替代 Boss 状态账本
- 不让 Hermes gateway 替代 Boss Web / Android 产品层
## 9. 风险与边界
### 9.1 最大风险:上下文主权混乱
Boss 有自己的 prompt、memory、project understanding、thread status。Hermes 也有:
- system prompt
- skills
- MEMORY / USER
- Honcho
- context files
- session search
如果不加边界,很容易出现:
- Boss 明明要求 A
- Hermes 自己的 memory / skill / context file 又暗示 B
- 最终输出谁在主导不清楚
所以必须坚持:
- Boss 决定业务上下文主权
- Hermes 只在 Boss 明确授权的范围内做 agentic 执行
### 9.2 第二大风险:权限模型不一致
Boss 的审批流是业务审批Hermes 的 approval 是 runtime 审批。这两者不是一回事。
如果直接混用,会出现:
- Boss 以为“已批准执行”
- 但 Hermes 还在等危险命令批准
或者:
- Hermes 因本地 allowlist 放过了命令
- Boss 却并未允许该业务动作
因此必须分层:
- Boss 负责业务许可
- Hermes 负责运行时危险动作许可
- 中间靠 policy mapping 对齐
### 9.3 第三大风险:状态与观测割裂
如果 Hermes 执行内部发生:
- 自动记忆写入
- session 压缩
- subagent delegation
- tool 失败重试
但 Boss 只能看到最终一段 stdout那么 Boss 无法做审计、归因和用户解释。
因此长期必须补:
- 最小运行元数据
- 最小工具活动摘要
- 明确失败分类
但这不等于把 Hermes 全量内部日志搬进 Boss。
### 9.4 第四大风险:运维依赖扩张
Hermes 引入的是一整套 Python runtime、provider 凭据、tool dependencies、MCP、optional extras。对 Boss 来说,这会带来:
- 安装复杂度增加
- 服务器镜像和本地设备环境差异
- 版本升级不一致
- provider 凭据与 Boss 账号配置割裂
所以 Hermes 长期必须维持“可选、可探测、可回退”,不能成为 Boss 单点依赖。
### 9.5 第五大风险:成本和上下文膨胀
Hermes 的强项也是风险点:
- 更强工具调用
- 更多记忆注入
- session search
- Honcho
- delegation
这些能力如果没有边界,会直接带来更高 token 成本和更长响应尾延迟。Boss 当前主 Agent 的部分场景并不需要这么重。
## 10. 升级策略
### 10.1 采用“能力 checkpoint”而不是“整仓跟随”
不要用“Boss 跟随 Hermes 最新版”这种策略,而要用能力 checkpoint
1. 选定一个 Hermes 版本
2. 验证一组 Boss 关心的能力:
- CLI one-shot
- model/provider 解析
- toolsets
- approvals
- session metadata
- optional MCP
3. 通过后才升级 Boss 适配层
这能避免 Boss 被 Hermes 高频演进拖着走。
### 10.2 维持严格的单向边界
推荐长期坚持以下边界:
- Boss -> Hermes
- `ExecutionRequest`
- policy 映射后的 runtime config
- 明确的 prompt 与 memory 注入
- Hermes -> Boss
- 标准化结果
- 最小 session metadata
- 最小 telemetry
不要让 Boss 直接依赖:
- Hermes 内部 SQLite schema
- Hermes 内部 Python 类
- Hermes 内部工具注册过程
### 10.3 先扩可观察性,再扩能力
每一次升级顺序都应是:
1. 先补 health / telemetry / fallback
2. 再开放新能力
例如:
- 想接 session resume先能看见 session 元数据
- 想接 delegation先能看见子任务摘要和失败分类
- 想接 Honcho先定义 Boss 侧的记忆归属边界
### 10.4 始终保留 kill switch
Hermes 长期必须是可关闭的:
- 配置级关闭
- 项目级关闭
- 运行时自动回退
- 前台明确提示回退原因
这是 Boss 能放心吸收外部 runtime 的前提。
### 10.5 用受控试点项目推进,而不是全局切换
升级节奏建议:
- 只在 `master-agent` 的少量项目启用
- 先对复杂任务启用
- 按项目或账号白名单试点
- 保留默认后端作对照
Boss 不应该出现“大版本直接切 Hermes 为默认”的动作。
## 11. 最终判断
### 11.1 长期定位
Hermes 在 Boss 中的长期定位应是:
- 第一身份:执行 runtime 候选
- 第二身份:运行时抽象设计参考
- 第三身份:未来 session-aware agent backend 候选
而不是:
- Boss 产品层替代者
- Boss 编排层替代者
- Boss 状态账本替代者
### 11.2 值得吸收的核心东西
Boss 最值得从 Hermes 吸收的,不是某个具体命令,而是这四个长期结构:
- runtime capability 分层
- tool / approval / backend 的统一治理
- session-aware agent runtime 的中间层设计
- 业务真相与外部 runtime 分离的架构纪律
### 11.3 不该动摇的 Boss 主权
Boss 必须继续自己掌握:
- 会话与项目模型
- 群聊与审批
- 设备导入
- 业务记忆与线程状态文档
- 用户可见产品语言
- 账本与聚合投影视图
Hermes 可以把 Boss 主 Agent 变强,但不应该把 Boss 变成 Hermes 的 UI 外壳。
## 12. 建议的下一步
建议按优先级做三件事。
### 12.1 先做一份第二阶段实施设计
主题应收敛为:
- `Hermes Runtime Capability / Policy / Telemetry 第二阶段设计`
只覆盖:
- capability profile
- session metadata 捕获
- policy mapping
- smoke / health / version 探测
- telemetry 与 fallback
不扩大到 dispatch、群聊、Honcho。
### 12.2 补齐真实上游证据
`/tmp/hermes-agent` 可用后,重新核对:
- `README.md`
- `docs/acp-setup.md`
- `docs/honcho-integration-spec.md`
重点看是否存在与官方公开文档不一致、且会影响 Boss 边界判断的实现细节。
### 12.3 建一个 Hermes 受控试点矩阵
至少分三类任务做对照:
- 纯问答型主 Agent 任务
- 多步工具执行任务
- 需要较强上下文整合的复杂任务
用实际结果决定 Hermes 在 Boss 中应该停留在“高级可选后端”还是继续推进到“session-aware 主 Agent runtime”。

View File

@@ -0,0 +1,112 @@
# Android Chat Status Row Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Render `conversationTasks` and `executionWarnings` as a compact inline status row beneath each message on the Android chat page, dedupe grouped warnings per message, and keep realtime patches scoped to the affected message.
**Architecture:** Add a reusable status-row builder in `BossUi` and wire `ProjectDetailActivity.buildMessageView` to compute grouped tasks/warnings, append the status row under each bubble, and compare a fingerprint during realtime patches so only impacted views re-render.
**Tech Stack:** Android/Java UI (`ProjectDetailActivity.java`, `BossUi.java`) and Node-powered source-verification tests (`tests/android-chat-incremental-realtime-append.test.ts`, `tests/android-chat-local-realtime-patch.test.ts`).
---
### Task 1: Fail on missing inline status row in realtime append test
**Files:**
- Modify: `tests/android-chat-incremental-realtime-append.test.ts`
- Test: `npm test tests/android-chat-incremental-realtime-append.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
assert.match(
source,
/wrapper\.addView\(BossUi\.buildMessageStatusRow\(/,
"expected every message bubble to append BossUi.buildMessageStatusRow",
);
assert.match(
source,
/List<JSONObject> messageWarnings = collectMessageWarnings\(projectMessagesPayload, messageId\);/,
"expected the new status row builder to source grouped warnings per message",
);
assert.match(
source,
/String statusFingerprint = buildStatusFingerprint\(messageId, projectMessagesPayload\);/,
"expected realtime patches to compute a fingerprint before replacing a message view",
);
```
- [ ] **Step 2: Run it to prove failure**
Run: `npm test tests/android-chat-incremental-realtime-append.test.ts`
Expected: FAIL because `BossUi.buildMessageStatusRow` and the new helper functions do not yet exist.
- [ ] **Step 3: Re-run after implementation to confirm success**
Run: `npm test tests/android-chat-incremental-realtime-append.test.ts`
Expected: PASS.
### Task 2: Fail on missing helper signatures in realtime patch test
**Files:**
- Modify: `tests/android-chat-local-realtime-patch.test.ts`
- Test: `npm test tests/android-chat-local-realtime-patch.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
assert.match(
source,
/LinearLayout statusRow = buildMessageStatusRow\(this, message, conversationTask, messageWarnings\);/,
"expected buildMessageStatusRow to be invoked when each message is rendered",
);
assert.match(
source,
/List<JSONObject> buildMessageWarnings\(JSONObject payload, String messageId\);/,
"expected a helper that returns grouped warnings keyed by message id",
);
assert.match(
source,
/if \(!TextUtils\.equals\(currentFingerprint, nextFingerprint\)\) \{ replaceMessageViewById\(/,
"expected realtime patch to compare status fingerprint before rerendering",
);
```
- [ ] **Step 2: Run it to prove failure**
Run: `npm test tests/android-chat-local-realtime-patch.test.ts`
Expected: FAIL because helper signatures and fingerprint comparisons are missing.
- [ ] **Step 3: Re-run after implementation to confirm success**
Run: `npm test tests/android-chat-local-realtime-patch.test.ts`
Expected: PASS.
### Task 3: Add BossUi helper for compact status row
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- Test: `npm test tests/android-chat-incremental-realtime-append.test.ts`
- Test: `npm test tests/android-chat-local-realtime-patch.test.ts`
- [ ] **Step 1: Implement `buildMessageStatusRow`**
Add a method that accepts `(Context context, String statusLabel, String detailText, boolean outgoing)` and returns a horizontal `LinearLayout` with spaced pills and body text, using rounded background colors and `TextView` ellipsizing. Keep it compact (max height) and re-usable for both warnings and conversation task summaries.
- [ ] **Step 2: Run targeted tests**
Run: `npm test tests/android-chat-incremental-realtime-append.test.ts tests/android-chat-local-realtime-patch.test.ts`
Expected: PASS after the helper is referenced by `ProjectDetailActivity`.
### Task 4: Wire ProjectDetailActivity to the new status row
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Test: `npm test tests/android-chat-incremental-realtime-append.test.ts`
- Test: `npm test tests/android-chat-local-realtime-patch.test.ts`
- [ ] **Step 1: Add helper methods for grouped warnings/tasks and fingerprinting**
Implement `List<JSONObject> collectMessageWarnings(JSONObject payload, String messageId)`, `@Nullable JSONObject findConversationTask(String messageId)`, and `private String buildStatusFingerprint(String messageId, JSONObject payload)` that concatenates task/w warning summaries so the realtime patch can detect status changes without re-rendering unchanged rows.
- [ ] **Step 2: Update `buildMessageView` to append a status row**
Inside `buildMessageView`, compute `List<JSONObject> messageWarnings` and `JSONObject messageTask`, then call `BossUi.buildMessageStatusRow(this, description, detail, outgoing)` and add it directly beneath the bubble instead of the previous warning card logic. Use the grouped data to deduplicate identical warnings (e.g., map by `warningId` or `title+summary`).
- [ ] **Step 3: Update realtime patch logic**
In `tryPatchRealtimeExecutionWarnings`, compute `String currentFingerprint = buildStatusFingerprint(messageId, currentRenderedProjectPayload)` and `String nextFingerprint = buildStatusFingerprint(messageId, projectMessagesPayload)`; only call `replaceMessageViewById` when they differ. Ensure `currentRenderedProjectPayload` is updated once per patch and that `renderNearBottom`/`setRefreshing` behavior stays the same.
- [ ] **Step 4: Run regression tests**
Run: `npm test tests/android-chat-incremental-realtime-append.test.ts tests/android-chat-local-realtime-patch.test.ts`
Expected: PASS.
### Task 5: Final verification
**Files:**
- Test: all above tests
- [ ] **Step 1: Run the two test files together**
Run: `npm test tests/android-chat-incremental-realtime-append.test.ts tests/android-chat-local-realtime-patch.test.ts`
Expected: PASS with no additional failures.

View File

@@ -0,0 +1,318 @@
# Hermes Backend MVP Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为 Boss 新增一个默认关闭、可显式启用的 `hermes-runtime`,让 `master-agent` 可通过 Hermes CLI 返回回复。
**Architecture:** 复用现有 `claw-runtime` 的接入形态,在 `src/lib/execution/backends/` 下新增 Hermes 配置、runner 与 backend adapter再把 selector、主 Agent 路由与前台控制接上。第一批只开放给 `master-agent`,不碰群聊编排和 Android 独立设置页。
**Tech Stack:** Next.js App Router, TypeScript, Node child_process, Node test runner
---
### Task 1: 补 Hermes 后端底座测试
**Files:**
- Create: `tests/hermes-backend-config.test.ts`
- Create: `tests/hermes-runner.test.ts`
- Create: `tests/hermes-backend.test.ts`
- Modify: `tests/execution-backend-selector.test.ts`
- [ ] **Step 1: 写配置与 selector 的失败测试**
```ts
test("Hermes backend 默认关闭且 selector 不会主动选中", () => {
const backends = listExecutionBackendChoices({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
});
assert.deepEqual(backends.map((item) => item.backendId), [
"master-codex-node",
"openai-api",
]);
});
```
- [ ] **Step 2: 写 runner 参数拼装失败测试**
```ts
test("Hermes runner 会拼 chat -q -Q --source tool 与可选模型参数", async () => {
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 30_000,
defaultModel: "gpt-5.4",
sourceTag: "tool",
},
payload: {
executionPrompt: "请输出链路正常",
model: "gpt-5.5",
},
});
assert.equal(result.status, "completed");
assert.equal(result.output, "Hermes smoke completed");
});
```
- [ ] **Step 3: 跑测试确认当前失败**
Run:
```bash
npx tsx --test tests/hermes-backend-config.test.ts tests/hermes-runner.test.ts tests/hermes-backend.test.ts tests/execution-backend-selector.test.ts
```
Expected:
- FAIL提示 Hermes 相关模块不存在或 selector 还不认识 `hermes-runtime`
---
### Task 2: 实现 Hermes config / runner / backend
**Files:**
- Create: `src/lib/execution/backends/hermes-config.ts`
- Create: `src/lib/execution/backends/hermes-runner.ts`
- Create: `src/lib/execution/backends/hermes-backend.ts`
- Create: `scripts/hermes-runtime-smoke.mjs`
- [ ] **Step 1: 实现 Hermes 配置读取与可用性检查**
实现点:
- `BOSS_HERMES_ENABLED`
- `BOSS_HERMES_COMMAND`
- `BOSS_HERMES_ARGS`
- `BOSS_HERMES_WORKDIR`
- `BOSS_HERMES_TIMEOUT_MS`
- `BOSS_HERMES_DEFAULT_MODEL`
- `BOSS_HERMES_TOOLSETS`
- `BOSS_HERMES_SKILLS`
- [ ] **Step 2: 实现 runner**
固定执行形态:
```text
<command> <prefix args> chat -q <executionPrompt> -Q --source tool
```
可选追加:
- `-m <model>`
- `-t <toolsets>`
- `-s <skills>`
解析 quiet 模式结尾的:
```text
session_id: xxx
```
但只把正文回给 Boss。
- [ ] **Step 3: 实现 backend adapter**
能力保持与 Claw 一致:
- backendId: `hermes-runtime`
- 仅支持 `master_agent_reply` / `thread_reply`
- `describe()` 返回稳定描述
- [ ] **Step 4: 跑测试确认转绿**
Run:
```bash
npx tsx --test tests/hermes-backend-config.test.ts tests/hermes-runner.test.ts tests/hermes-backend.test.ts tests/execution-backend-selector.test.ts
```
Expected:
- PASS
---
### Task 3: 把 Hermes 接入 selector 与主 Agent 控制面
**Files:**
- Modify: `src/lib/execution/backend-selector.ts`
- Modify: `src/lib/boss-data.ts`
- Modify: `src/app/api/v1/projects/[projectId]/agent-controls/route.ts`
- Modify: `src/app/api/v1/projects/[projectId]/prompt-profile/route.ts`
- Modify: `src/app/me/master-agent/page.tsx`
- Modify: `src/components/master-agent-prompt-memory-client.tsx`
- Modify: `tests/master-agent-chat-controls.test.ts`
- [ ] **Step 1: 扩展 `backendOverride` 合法值**
把:
```ts
backendOverride?: "claw-runtime";
```
扩为:
```ts
backendOverride?: "claw-runtime" | "hermes-runtime";
```
并同步更新 `parseBackendOverride()`
- [ ] **Step 2: 扩展路由校验**
保存 `agent-controls` / `prompt-profile` 时:
- 允许 `hermes-runtime`
- 若 Hermes 当前不可选,直接返回 400 和原因
- [ ] **Step 3: 扩展 Web 控制面**
主 Agent 提示词页的执行后端下拉新增:
- `Hermes Runtime`
并显示 Hermes 的可用性提示。
- [ ] **Step 4: 跑控制面测试**
Run:
```bash
npx tsx --test tests/master-agent-chat-controls.test.ts
```
Expected:
- PASS`backendOverride: "hermes-runtime"` 可读写
---
### Task 4: 接上 `boss-master-agent` 执行链
**Files:**
- Modify: `src/lib/boss-master-agent.ts`
- Modify: `tests/master-agent-message-queue.test.ts`
- [ ] **Step 1: 写 Hermes 主 Agent 执行失败测试**
新增与 Claw 对齐的测试:
- 显式选择 `hermes-runtime`
- Hermes 返回结果后能写回主 Agent 会话
- 不可用时会回退或返回明确失败
- [ ] **Step 2: 实现 `replyViaHermesBackend` 与 `enqueueHermesMasterAgentReply`**
保持与 Claw 同一层级:
- 同步模式直接执行并回写
- enqueue 模式先排任务,再异步执行 Hermes
- Hermes 失败时按当前主链回退到 API / Master Node
- [ ] **Step 3: 在 selector 结果里接入 Hermes 分支**
在:
- `replyToMasterAgentUserMessage()`
里新增:
- `selectedBackend.backendId === HERMES_BACKEND_ID`
对应处理。
- [ ] **Step 4: 跑消息链路测试**
Run:
```bash
npx tsx --test tests/master-agent-message-queue.test.ts
```
Expected:
- PASS`master-agent` 显式选 Hermes 时会通过 Hermes 返回
---
### Task 5: 全量验证与文档收口
**Files:**
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
- [ ] **Step 1: 更新运行时文档**
补充:
- `HermesBackendAdapter` 已最小接入
- 默认关闭
- 当前只对 `master-agent` 开放
- [ ] **Step 2: 运行相关测试**
Run:
```bash
npx tsx --test tests/hermes-backend-config.test.ts tests/hermes-runner.test.ts tests/hermes-backend.test.ts tests/execution-backend-selector.test.ts tests/master-agent-chat-controls.test.ts tests/master-agent-message-queue.test.ts
```
Expected:
- PASS
- [ ] **Step 3: 运行基础验证**
Run:
```bash
npm run build
npm run lint
```
Expected:
- `build` PASS
- `lint` 若失败,只能是仓库已知外来大文件噪音,不能是本批新增文件
- [ ] **Step 4: 提交**
```bash
git add docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md \
docs/superpowers/plans/2026-04-14-hermes-backend-mvp.md \
src/lib/execution/backends/hermes-config.ts \
src/lib/execution/backends/hermes-runner.ts \
src/lib/execution/backends/hermes-backend.ts \
src/lib/execution/backend-selector.ts \
src/lib/boss-data.ts \
src/lib/boss-master-agent.ts \
src/app/api/v1/projects/[projectId]/agent-controls/route.ts \
src/app/api/v1/projects/[projectId]/prompt-profile/route.ts \
src/app/me/master-agent/page.tsx \
src/components/master-agent-prompt-memory-client.tsx \
scripts/hermes-runtime-smoke.mjs \
tests/hermes-backend-config.test.ts \
tests/hermes-runner.test.ts \
tests/hermes-backend.test.ts \
tests/execution-backend-selector.test.ts \
tests/master-agent-chat-controls.test.ts \
tests/master-agent-message-queue.test.ts \
docs/architecture/current_runtime_and_deploy_status_cn.md \
docs/architecture/api_and_service_inventory_cn.md
git commit -m "feat: add hermes runtime backend for master agent"
```
---
## Self-Review
- 这份计划只覆盖 `master-agent`没有把群聊编排、Android 独立配置页和 Hermes session 持久化一起卷进来
- 所有代码改动都围绕现有 `claw-runtime` 接入形态做最小差异实现
- 第一批验收点是“能选、能跑、能回退”,不是“把 Hermes 全部接满”

View File

@@ -0,0 +1,36 @@
# Android chat status row spec
## Goal
Surface `conversationTasks` and `executionWarnings` as a single compact status row under each Android chat bubble so owners can see backend work without stacking multiple cards, while keeping realtime patches minimal.
## Motivation
- The current Android chat preview renders separate warning cards below every message and keeps an expensive list of `executionWarnings` separately, which duplicates the tap area and height of the chat list.
- The Web page already summarizes tasks and warnings inline: we should match that on Android while keeping real-time patches restrictive, only rejiggering views for the affected message.
- Requirements from the user: group warnings per message, deduplicate statuses, update only affected message on realtime patches, no web/backend changes.
## Architecture
1. Extend `BossUi.buildMessageBubble` (or its wrapper) to accept a reusable compact status row view that can show a grouped warning count and task status.
2. When building a message view in `ProjectDetailActivity.buildMessageView`, gather all conversation task summaries and execution warnings that match the message ID. Deduplicate them by warning ID or Task ID and render them inside the status row.
3. Create helper methods to compute a `StatusRowView` (a `LinearLayout`) that displays:
- If there is an active `conversationTask`, show a row with the backend status label and session/task info.
- If there are warnings, show badge summaries with title and summary text but limit to one line (maybe ellipsized) and collapse duplicates by grouping warnings that share the same title+summary pair.
4. `buildMessageView` will add the status row view directly under the existing bubble but before any other attachments.
5. The realtime warning patch logic already replaces a specific `messageId` view; ensure it reconstructs the same minimal status row and only rerenders when the status content changes. To do that, we may need to compute a canonical string for the combined task/warnings and store/compare to avoid redundant replacements.
## Implementation details
- Introduce new helper methods in `ProjectDetailActivity`:
- `private LinearLayout buildStatusRow(JSONObject message, List<ConversationTaskSummary> tasks, JSONArray warnings)`
- `private boolean hasStatusChangesForMessage(...)` to allow realtime patch to compare old vs new statuses without rerender unless necessary.
- `BossUi` may need a new method such as `public static LinearLayout buildMessageStatusRow(Context context, String label, String detail, int tintColor)` to standardize the UI.
- Update `ProjectDetailActivity.appendContent(buildMessageView(message))` loops to clear prior warning cards and add the new status row once.
- Keep logic around `findExecutionWarningForMessage` but adjust to return grouped data instead of each warning separately.
- Update `currentRenderedProjectPayload` handling to consider the grouped warning/task text, ensuring `hasSameExecutionWarningForMessage` returns `true` when summaries and counts match.
## Testing approach (TDD)
1. Add a new or expand existing Android tests that read `ProjectDetailActivity.java` source to assert the presence of status row construction, grouped warnings, and realtime patch logic (e.g., `tests/android-chat-incremental-realtime-append.test.ts` and `tests/android-chat-local-realtime-patch.test.ts`).
2. Write a failing test first that expects a new helper like `buildMessageStatusRow` or `getStatusTextForMessage` to exist and to be used under each message.
3. Update tests referencing warning cards to expect the new inline status row and ensure patch logic still references the helper to rerender targeted messages only when necessary.
4. Manually run `npm test android-chat-incremental-realtime-append.test.ts` and `npm test android-chat-local-realtime-patch.test.ts` after implementing.
## Validation
- After receiving user approval on this spec, proceed with the implementation plan using TDD. Use test file names to focus runs, and keep the Android changes confined to `ProjectDetailActivity.java`, `BossUi.java`, and the corresponding tests.

View File

@@ -0,0 +1,271 @@
# Boss `HermesBackendAdapter` 最小接入设计
## 1. 背景
Boss 现在已经有一层稳定的执行底座:
- `ExecutionBackend`
- `ExecutionBackendSelector`
- `PromptAssembler`
- `PermissionPolicy`
- `RemoteRuntimeAdapter`
并且已经接过两个外部项目:
- `ClawBackendAdapter` 负责单次执行候选
- `OmxTeamBackendAdapter` 负责编排候选
`hermes-agent` 的最新上游形态更像“重型 agent runner”而不是 Boss 的产品层替代物。它的价值主要集中在:
- 成熟的单次 agent loop
- CLI / Gateway / ACP 多入口
- 丰富的 toolset 体系
- 内建 skills / memory / session / search / delegation
结论不是“把 Hermes 整体搬进 Boss”而是把它当成新的**执行后端候选**接进 Boss 现有底座。
---
## 2. 第一批目标
第一批只做一件事:
> 在不改变 Boss 当前生产主链默认行为的前提下,为 `master-agent` 新增一个默认关闭、可显式启用的 `hermes-runtime`。
用户能在 Boss 现有的主 Agent 对话控制里选择 `Hermes Runtime`,然后让当前 `master-agent` 的回复通过 Hermes CLI 完成。
这批做完后Boss 获得的是:
- 一个新的可选执行后端
- 一个可继续升级的 Hermes 适配点
- 对现有 `claw-runtime` / API / Master Codex Node 主链零破坏
---
## 3. 明确不做
这一批明确不做:
- 不把 Hermes 的 gateway、Telegram、Discord、Slack、WhatsApp、Signal 接进 Boss
- 不把 Hermes 的 Honcho memory 直接并入 Boss 的 `threadStatusDocuments / threadProgressEvents`
- 不让 Boss 前台直接理解 Hermes 的 sessions、slash commands、toolsets 内部结构
- 不接 Hermes 的多平台消息收发
- 不接 Hermes 的 cron / ACP / editor integration
- 不改 Boss 群聊编排链路
- 不把 Hermes 当成新的 OrchestrationBackend
这一批的定位非常明确:**只加一个执行后端,不加一个新产品子系统。**
---
## 4. 为什么 Hermes 值得接
### 4.1 对主 Agent 的意义
对 Boss 的主 Agent 来说Hermes 的参考价值主要有三层:
1. **成熟的一次性 agent loop**
- `hermes chat -q ... -Q` 已经提供了可脚本化的单次非交互执行入口。
- 这正好适合 Boss 当前 `ExecutionBackend` 的“单次请求 -> 单次结果”契约。
2. **比 Claw 更完整的 agent runtime**
- Hermes 不只是命令封装,而是完整的 agent runner、tool registry、toolsets、skills、memory、delegation 体系。
- 这意味着它对复杂任务的“自己调工具完成”的能力更强,适合作为 Boss 主 Agent 的增强执行候选。
3. **未来跨端能力的扩展面更大**
- Hermes 天然有 CLI / Gateway / ACP 三个入口。
- 这对 Boss 未来做“同一 agent 内核,挂不同入口”很有参考价值,但第一批先不展开。
### 4.2 对 Boss 整体业务流程的意义
Hermes 对 Boss 的真正价值不在 UI而在**执行层的替换与对照实验**
- 同一条 Boss 产品链
- 同一组项目、线程、审批、导入、群聊数据
- 不同执行后端并行存在
这样 Boss 可以把“产品层稳定”与“执行层可替换”真正做实。
---
## 5. 第一批接入方案
### 5.1 接入位置
Hermes 第一批接在:
- `src/lib/execution/backends/`
- `ExecutionBackendSelector`
- `master-agent` 对话控制
不接:
- `OrchestrationBackend`
- `local-agent` dispatch_execution
- Android 独立配置页
### 5.2 运行方式
Boss 不直接 import Hermes Python 代码,也不把 Hermes vendoring 进仓库。
Boss 通过外部命令调用 Hermes
```text
<configured command> <configured prefix args> chat -q "<executionPrompt>" -Q --source tool
```
按需追加:
- `-m <model>`
- `-t <toolsets>`
- `-s <skills>`
这样做的原因:
- 上游升级成本最低
- Boss 与 Hermes 保持进程边界
- 可以像 `claw-runtime` 一样通过命令 / workdir / args 做显式配置
- 出问题时可直接回退,不污染主链
### 5.3 输入输出契约
Boss -> Hermes
- `executionPrompt`
- `modelOverride`
- `reasoningEffortOverride`(第一批只透传给 Boss 自身记录,不强行映射到 Hermes CLI 参数)
- 可选 `toolsets`
- 可选 `skills`
Hermes -> Boss
- `stdout` 主体内容作为最终回复
- quiet mode 末尾的 `session_id: ...` 只做解析,不写回会话正文
如果 Hermes 非零退出、超时、输出为空或结构不可解析,统一转成:
- `ExecutionImmediateFailedResult`
---
## 6. 配置设计
新增环境变量:
- `BOSS_HERMES_ENABLED`
- `BOSS_HERMES_COMMAND`
- `BOSS_HERMES_ARGS`
- `BOSS_HERMES_WORKDIR`
- `BOSS_HERMES_TIMEOUT_MS`
- `BOSS_HERMES_DEFAULT_MODEL`
- `BOSS_HERMES_TOOLSETS`
- `BOSS_HERMES_SKILLS`
设计约束:
- 默认关闭
- `enabled=true` 时若未显式设置 `BOSS_HERMES_COMMAND`,默认尝试 `hermes`
- 可执行入口不存在、工作目录不存在、前置脚本不存在时,前台不允许选择
---
## 7. Boss 内部改动点
### 7.1 新增文件
- `src/lib/execution/backends/hermes-config.ts`
- `src/lib/execution/backends/hermes-runner.ts`
- `src/lib/execution/backends/hermes-backend.ts`
- `scripts/hermes-runtime-smoke.mjs`
### 7.2 修改文件
- `src/lib/execution/backend-selector.ts`
- `src/lib/boss-data.ts`
- `src/lib/boss-master-agent.ts`
- `src/app/api/v1/projects/[projectId]/agent-controls/route.ts`
- `src/app/api/v1/projects/[projectId]/prompt-profile/route.ts`
- `src/app/me/master-agent/page.tsx`
- `src/components/master-agent-prompt-memory-client.tsx`
### 7.3 测试
新增或扩展:
- `tests/hermes-backend-config.test.ts`
- `tests/hermes-runner.test.ts`
- `tests/hermes-backend.test.ts`
- `tests/execution-backend-selector.test.ts`
- `tests/master-agent-chat-controls.test.ts`
- `tests/master-agent-message-queue.test.ts`
---
## 8. 关键取舍
### 8.1 第一批不做 session 级复用
Hermes quiet mode 会回 `session_id`,但 Boss 第一批不把它纳入正式状态模型。
原因:
- Boss 现有 `ExecutionImmediateResult` 没有 session 归档职责
- 当前目标是先把“后端切换可用”做通
- 先引入 session 绑定会把 `thread/session` 关联、恢复、清理都一起带进来,范围会失控
第一批策略:
- 解析但不持久化 `session_id`
- 先跑通 stateless one-shot backend
### 8.2 第一批不把 Boss PermissionPolicy 动态映射成 Hermes toolsets
Hermes 的 toolsets 很强,但 Boss 现在没有现成的“工具级权限 -> Hermes CLI 参数”双向映射层。
第一批策略:
- 只支持静态环境变量指定 `toolsets` / `skills`
- Boss 自己仍保留顶层产品审批与后端选择权
这样虽然不够极致,但风险最小。
### 8.3 第一批只开放给 `master-agent`
这是刻意收口,不是能力缺陷。
原因:
- 当前 Boss 的后端 override UI 只在主 Agent 对话控制里成熟
- `dispatch_execution` 牵涉群聊编排与设备执行链,接 Hermes 会把范围扩大到第二批
- 先让主 Agent 对话用起来,收益最大、回归面最小
---
## 9. 验收标准
满足以下条件才算第一批完成:
1. 未启用 `BOSS_HERMES_*`Boss 行为与当前完全一致
2. 启用且配置正确后,`master-agent` 对话控制中可选择 `Hermes Runtime`
3. 选择 `Hermes Runtime` 后,主 Agent 单次回复能通过 Hermes CLI 返回结果
4. Hermes 不可用时,保存接口会拒绝选择,并返回明确原因
5. 历史保存了 `hermes-runtime` 但当前不可用时,运行时会自动回退到默认后端,并给出人类可读说明
6. `npm run build`
7. `npm run lint`(当前需排除仓库外来大文件噪音后)
8. 相关测试通过
---
## 10. 第二批预留方向
第一批完成后,再考虑第二批:
- Hermes session 级复用与 Boss 项目线程关联
- Boss 权限策略到 Hermes toolsets 的映射
- `thread_reply` 正式挂接
- Android 主 Agent 设置页显示 Hermes 可用性
- `dispatch_execution` 是否引入 Hermes 作为编排前执行候选
第一批的成功标准不是“把 Hermes 接满”,而是:
> 让 Boss 在现有产品层完全不变的情况下,稳定多出一个可选择、可回退、可继续升级的重型 agent 执行后端。

View File

@@ -0,0 +1,131 @@
# 主 Agent Fast Path 设计
更新时间:`2026-04-16`
## 1. 背景
主 Agent 当前同时承担两类请求:
- 需要深度理解、规划、协调的慢路径问题
- 只需要读取本地状态、快速执行配置变更的快路径问题
在原实现里,只有少量模型切换命令会本地直答。大量高频问题,例如“你现在是什么大模型”“当前后端是什么”,仍会落入主推理链,导致:
- 回复延迟明显偏高
- 消耗不必要的 token
- 用户需要记忆更机械的命令句式
## 2. 设计目标
- 把“状态查询 / 配置操作 / 枚举类问题”从主推理链中剥离出来
- 对高频确定性问题提供本地直答,不进入异步队列
- 保持自然语言容错,不要求用户输入完全标准化命令
- 为后续继续扩展快路径意图提供统一入口
## 3. 方案
### 3.1 Fast Path Router
`src/lib/boss-master-agent.ts` 中新增主 Agent 快路径路由层:
- 统一先构造 `MasterAgentFastIntentContext`
- 上下文内聚合:
- 当前登录用户作用域下的 `agentControls`
- 可见模型列表
- 可用模型列表
- 当前聊天意图的实际模型策略
- 深度任务意图的实际模型策略
- 当前 runtime 主控来源
- `replyToMasterAgentUserMessage()` 在进入主链前先尝试 `tryHandleMasterAgentFastIntent()`
### 3.2 第一批接入意图
- 模型列表查询
- 例:“有哪些模型可以用”
- 模型切换
- 例:“把快模型切到 gpt5.4mini”
- 当前模型状态查询
- 例:“你现在是什么大模型”
- 例:“当前主模型是什么”
- 例:“快模型是什么”
- 例:“强模型是什么”
- 当前后端状态查询
- 例:“当前后端是什么”
### 3.3 自然语言归一化
模型名解析新增轻量归一化:
- 忽略 `- / _ / 空格 / .`
- 兼容 `gpt5.4``gpt 5.4``gpt_5_4`
- 仍然会回落到系统当前可选模型列表做映射
## 4. 展示规则
主 Agent 快路径回复的发送者名称改成:
- `主Agent·<当前模型名>`
Android 主 Agent 会话页顶部标题同步显示:
- `主Agent·<当前模型名>`
如果当前拿不到显式模型,则退回:
- `主Agent`
## 5. 当前收益
- “查状态 / 查配置 / 改模型”这一类问题能直接秒回
- 主 Agent 不再因为低价值查询进入慢路径
- 用户不用死记标准句式
- 后续扩展更多快路径意图时,只需要继续往 router 中追加 handler
## 6. 后续扩展建议
下一批适合继续接入 Fast Path 的问题:
- 当前会话 / 当前线程运行状态查询
- 最近活跃项目 / 最近活跃线程查询
- 更细粒度的线程级接管控制与作用域切换
## 6.1 第二批已接入意图
本次继续把主 Agent 的“控制面”高频问题接入快路径:
- 全局接管状态查询
-“当前有没有开启主agent接管”
- 全局接管开关
- 例:“帮我开启全局接管”
- 例:“关闭全局接管”
- 默认后端切换
- 例:“把默认后端切到 Hermes”
- 例:“切到 Claw 后端”
- 默认执行模式查询
- 例:“现在默认走 GUI 还是 CLI”
- 当前主节点 / 绑定设备在线状态查询
- 例:“当前主节点在线吗”
这批实现原则是:
- 只处理确定性、纯本地状态可回答的问题
- 不进入异步任务队列
- 先命中接管 / 后端 / 执行模式,再命中模型切换,避免“切到 Hermes 后端”被模型切换规则误判
- `GUI 还是 CLI``Hermes 还是 Claw` 分属不同意图,不再混用一个正则
## 7. 验证基线
本次落地至少要求以下验证通过:
- `npx tsx --test tests/master-agent-message-queue.test.ts tests/master-agent-chat-controls.test.ts`
- `npm run build`
- `./gradlew :app:compileDebugJavaWithJavac :app:assembleDebug`
- 真机安装并验证主 Agent 名称与模型查询行为
第二批补充验证点:
- `npx tsx --test tests/master-agent-message-queue.test.ts`
- 覆盖全局接管查询 / 切换
- 覆盖默认后端切换
- 覆盖 GUI/CLI 执行模式查询
- 覆盖主节点设备在线状态查询

View File

@@ -235,6 +235,10 @@ export async function prepareCodexTaskExecution(config, task, outputFile) {
export function buildCodexTaskExecution(config, task, outputFile) {
const { targetThreadRef, cwd } = resolveResumeTarget(config, task);
const prompt = String(task?.executionPrompt || "");
const taskExecutionModel = typeof task?.executionModel === "string" && task.executionModel.trim()
? task.executionModel.trim()
: null;
const effectiveModel = taskExecutionModel || config.masterAgentModel;
if (
targetThreadRef &&
@@ -247,8 +251,8 @@ export function buildCodexTaskExecution(config, task, outputFile) {
"-o",
outputFile,
];
if (config.masterAgentModel) {
args.push("-m", config.masterAgentModel);
if (effectiveModel) {
args.push("-m", effectiveModel);
}
args.push(targetThreadRef, prompt);
return {
@@ -269,8 +273,8 @@ export function buildCodexTaskExecution(config, task, outputFile) {
"-o",
outputFile,
];
if (config.masterAgentModel) {
args.push("-m", config.masterAgentModel);
if (effectiveModel) {
args.push("-m", effectiveModel);
}
args.push(prompt);
return {

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const args = process.argv.slice(2);
const queryIndex = args.findIndex((item) => item === "-q" || item === "--query");
const modelIndex = args.findIndex((item) => item === "-m" || item === "--model");
const toolsetsIndex = args.findIndex((item) => item === "-t" || item === "--toolsets");
const skillsIndex = args.findIndex((item) => item === "-s" || item === "--skills");
const sourceIndex = args.findIndex((item) => item === "--source");
const query = queryIndex >= 0 ? args[queryIndex + 1] ?? "" : "";
const model = modelIndex >= 0 ? args[modelIndex + 1] ?? "default" : "default";
const toolsets = toolsetsIndex >= 0 ? args[toolsetsIndex + 1] ?? "" : "";
const skills = skillsIndex >= 0 ? args[skillsIndex + 1] ?? "" : "";
const source = sourceIndex >= 0 ? args[sourceIndex + 1] ?? "" : "";
process.stdout.write(
`Hermes smoke completed: ${query} (model=${model}, toolsets=${toolsets || "default"}, skills=${skills || "default"}, source=${source || "default"})\n\n`,
);
process.stdout.write("session_id: hermes-smoke-session\n");

View File

@@ -13,6 +13,10 @@ export async function POST(
replyBody?: string;
errorMessage?: string;
requestId?: string;
warnings?: Array<{
title?: string;
summary?: string;
}>;
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
@@ -42,6 +46,7 @@ export async function POST(
replyBody: normalized.replyBody,
errorMessage: normalized.errorMessage,
requestId: normalized.requestId,
warnings: normalized.warnings,
dispatchExecutionId: normalized.dispatchExecutionId,
targetProjectId: normalized.targetProjectId,
targetThreadId: normalized.targetThreadId,

View File

@@ -3,12 +3,39 @@ import { requireRequestSession } from "@/lib/boss-auth";
import {
getProjectAgentControls,
hasPersistedProject,
listAiAccounts,
updateProjectAgentControls,
} from "@/lib/boss-data";
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
import { jsonNoStore } from "@/lib/api-response";
const reasoningEffortValues = new Set(["low", "medium", "high"]);
const MASTER_AGENT_MODEL_PRESETS = ["gpt-5.4", "gpt-5.4-mini", "gpt-4.1", "gpt-4.1-mini", "qwen3.5-plus"] as const;
async function buildMasterAgentModelCatalog() {
const aiAccounts = await listAiAccounts();
const availableModels = Array.from(
new Set(
aiAccounts.accounts
.filter((account) => account.canGenerate && account.model?.trim())
.map((account) => account.model!.trim()),
),
);
const configuredModels = Array.from(
new Set(
aiAccounts.accounts
.map((account) => account.model?.trim())
.filter((model): model is string => Boolean(model)),
),
);
return {
availableModels,
selectableModels: Array.from(new Set([...MASTER_AGENT_MODEL_PRESETS, ...configuredModels])),
presetModels: [...MASTER_AGENT_MODEL_PRESETS],
};
}
export async function GET(
request: NextRequest,
@@ -25,11 +52,14 @@ export async function GET(
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const [controls, clawAvailability] = await Promise.all([
const [controls, clawAvailability, hermesAvailability] = await Promise.all([
getProjectAgentControls(projectId, session.account),
getClawBackendAvailability(),
getHermesBackendAvailability(),
]);
return jsonNoStore({ ok: true, controls, clawAvailability });
const modelCatalog =
projectId === "master-agent" ? await buildMasterAgentModelCatalog() : undefined;
return jsonNoStore({ ok: true, controls, clawAvailability, hermesAvailability, modelCatalog });
}
export async function POST(
@@ -56,6 +86,10 @@ export async function POST(
const payload = body as {
modelOverride?: unknown;
reasoningEffortOverride?: unknown;
fastModelOverride?: unknown;
fastReasoningEffortOverride?: unknown;
smartModelOverride?: unknown;
smartReasoningEffortOverride?: unknown;
promptOverride?: unknown;
backendOverride?: unknown;
takeoverEnabled?: unknown;
@@ -66,6 +100,16 @@ export async function POST(
payload,
"reasoningEffortOverride",
);
const hasFastModelOverride = Object.prototype.hasOwnProperty.call(payload, "fastModelOverride");
const hasFastReasoningEffortOverride = Object.prototype.hasOwnProperty.call(
payload,
"fastReasoningEffortOverride",
);
const hasSmartModelOverride = Object.prototype.hasOwnProperty.call(payload, "smartModelOverride");
const hasSmartReasoningEffortOverride = Object.prototype.hasOwnProperty.call(
payload,
"smartReasoningEffortOverride",
);
const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride");
const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride");
const hasTakeoverEnabled = Object.prototype.hasOwnProperty.call(payload, "takeoverEnabled");
@@ -75,16 +119,24 @@ export async function POST(
? new Set([
"modelOverride",
"reasoningEffortOverride",
"fastModelOverride",
"fastReasoningEffortOverride",
"smartModelOverride",
"smartReasoningEffortOverride",
"promptOverride",
"backendOverride",
"globalTakeoverEnabled",
])
: new Set(["takeoverEnabled"]);
: new Set(["takeoverEnabled", "backendOverride"]);
const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key));
if (
(
!hasModelOverride &&
!hasReasoningEffortOverride &&
!hasFastModelOverride &&
!hasFastReasoningEffortOverride &&
!hasSmartModelOverride &&
!hasSmartReasoningEffortOverride &&
!hasPromptOverride &&
!hasBackendOverride &&
!hasTakeoverEnabled &&
@@ -110,6 +162,64 @@ export async function POST(
{ status: 400 },
);
}
if (
hasFastModelOverride &&
payload.fastModelOverride !== undefined &&
payload.fastModelOverride !== null &&
typeof payload.fastModelOverride !== "string"
) {
return NextResponse.json({ ok: false, message: "INVALID_FAST_MODEL_OVERRIDE" }, { status: 400 });
}
if (
hasFastReasoningEffortOverride &&
payload.fastReasoningEffortOverride !== undefined &&
payload.fastReasoningEffortOverride !== null &&
typeof payload.fastReasoningEffortOverride !== "string"
) {
return NextResponse.json(
{ ok: false, message: "INVALID_FAST_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (
hasFastReasoningEffortOverride &&
typeof payload.fastReasoningEffortOverride === "string" &&
!reasoningEffortValues.has(payload.fastReasoningEffortOverride)
) {
return NextResponse.json(
{ ok: false, message: "INVALID_FAST_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (
hasSmartModelOverride &&
payload.smartModelOverride !== undefined &&
payload.smartModelOverride !== null &&
typeof payload.smartModelOverride !== "string"
) {
return NextResponse.json({ ok: false, message: "INVALID_SMART_MODEL_OVERRIDE" }, { status: 400 });
}
if (
hasSmartReasoningEffortOverride &&
payload.smartReasoningEffortOverride !== undefined &&
payload.smartReasoningEffortOverride !== null &&
typeof payload.smartReasoningEffortOverride !== "string"
) {
return NextResponse.json(
{ ok: false, message: "INVALID_SMART_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (
hasSmartReasoningEffortOverride &&
typeof payload.smartReasoningEffortOverride === "string" &&
!reasoningEffortValues.has(payload.smartReasoningEffortOverride)
) {
return NextResponse.json(
{ ok: false, message: "INVALID_SMART_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") {
return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 });
}
@@ -117,7 +227,8 @@ export async function POST(
hasBackendOverride &&
payload.backendOverride !== undefined &&
payload.backendOverride !== null &&
payload.backendOverride !== "claw-runtime"
payload.backendOverride !== "claw-runtime" &&
payload.backendOverride !== "hermes-runtime"
) {
return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 });
}
@@ -149,11 +260,29 @@ export async function POST(
}
}
if (hasBackendOverride && payload.backendOverride === "hermes-runtime") {
const hermesAvailability = await getHermesBackendAvailability();
if (!hermesAvailability.selectable) {
return NextResponse.json(
{ ok: false, message: hermesAvailability.reasonLabel, hermesAvailability },
{ status: 400 },
);
}
}
const controls = await updateProjectAgentControls(
projectId,
{
...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}),
...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}),
...(hasFastModelOverride ? { fastModelOverride: payload.fastModelOverride } : {}),
...(hasFastReasoningEffortOverride
? { fastReasoningEffortOverride: payload.fastReasoningEffortOverride }
: {}),
...(hasSmartModelOverride ? { smartModelOverride: payload.smartModelOverride } : {}),
...(hasSmartReasoningEffortOverride
? { smartReasoningEffortOverride: payload.smartReasoningEffortOverride }
: {}),
...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}),
...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}),
...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}),
@@ -165,6 +294,7 @@ export async function POST(
ok: true,
controls: controls ?? null,
clawAvailability: await getClawBackendAvailability(),
hermesAvailability: await getHermesBackendAvailability(),
});
} catch (error) {
return NextResponse.json(

View File

@@ -152,6 +152,7 @@ export async function POST(
masterReplyState?: "queued" | "running" | "completed";
task?: {
taskId: string;
requestMessageId: string;
taskType: "conversation_reply";
status: "queued" | "running" | "completed";
};
@@ -160,6 +161,7 @@ export async function POST(
let task:
| {
taskId: string;
requestMessageId: string;
taskType: "conversation_reply";
status: "queued" | "running" | "completed";
}
@@ -213,6 +215,7 @@ export async function POST(
});
task = {
taskId: queuedTask.taskId,
requestMessageId: queuedTask.requestMessageId,
taskType: "conversation_reply",
status: "queued",
};
@@ -232,11 +235,11 @@ export async function POST(
currentSessionExpiresAt: session.expiresAt,
mode: "enqueue",
});
if (masterReply?.ok && masterReply.taskId) {
task = masterReply.task ?? null;
if (masterReply) {
if (masterReply.ok && masterReply.taskId) {
task = masterReply.task ?? null;
}
masterReplyState = masterReply.masterReplyState ?? null;
} else {
masterReplyState = null;
}
}

View File

@@ -1,122 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { isDispatchableThreadProject, readState, replaceGroupChatMembers } from "@/lib/boss-data";
import { readState, replaceGroupChatMembers } from "@/lib/boss-data";
import { buildProjectParticipantsPayload } from "@/lib/boss-projections-shared";
import { jsonNoStore } from "@/lib/api-response";
type ConversationParticipant = {
projectId: string;
deviceId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
avatar: string;
isSourceProject: boolean;
status: "active" | "missing_project" | "invalid_target";
statusLabel?: string;
canOpenProject: boolean;
};
function getFallbackAvatar(label: string) {
const trimmed = label.trim();
if (!trimmed) return "A";
return trimmed.slice(0, 1).toUpperCase();
}
function buildParticipant(
projectId: string,
deviceId: string,
threadId: string,
threadDisplayName: string,
folderName: string,
avatar?: string,
isSourceProject = false,
status: ConversationParticipant["status"] = "active",
canOpenProject = true,
): ConversationParticipant {
return {
projectId,
deviceId,
threadId,
threadDisplayName,
folderName,
avatar: avatar?.trim() || getFallbackAvatar(threadDisplayName),
isSourceProject,
status,
statusLabel:
status === "missing_project"
? "引用已失效"
: status === "invalid_target"
? "不是可下发线程"
: undefined,
canOpenProject,
};
}
function buildParticipantsPayload(
state: Awaited<ReturnType<typeof readState>>,
projectId: string,
) {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
function mapParticipantsRepairErrorMessage(error: unknown) {
if (!(error instanceof Error)) {
return "UNKNOWN_ERROR";
}
const participants = project.isGroup
? project.groupMembers.map((member) => {
const candidateProject = state.projects.find((item) => item.id === member.projectId);
const device = state.devices.find((item) => item.id === member.deviceId);
const status: ConversationParticipant["status"] = !candidateProject
? "missing_project"
: isDispatchableThreadProject(candidateProject)
? "active"
: "invalid_target";
return buildParticipant(
member.projectId,
member.deviceId,
member.threadId,
member.threadDisplayName,
member.folderName,
device?.avatar,
member.projectId === project.id,
status,
Boolean(candidateProject),
);
})
: [
buildParticipant(
project.id,
project.deviceIds[0] ?? project.id,
project.threadMeta.threadId,
project.threadMeta.threadDisplayName,
project.threadMeta.folderName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
true,
),
];
const validParticipantCount = participants.filter((item) => item.status === "active").length;
const invalidParticipantCount = participants.length - validParticipantCount;
const repairRequired =
project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2);
const repairReason = !repairRequired
? undefined
: validParticipantCount === 0
? "当前群聊里还没有可下发的真实线程,请重新添加线程。"
: invalidParticipantCount > 0
? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"
: "当前群聊至少需要 2 个真实线程成员。";
return {
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
repairRequired,
repairReason,
validParticipantCount,
invalidParticipantCount,
};
switch (error.message) {
case "GROUP_CHAT_MEMBER_NOT_FOUND":
return "有线程已经不存在,请刷新后重新选择。";
case "GROUP_CHAT_MEMBER_NOT_THREAD":
return "所选项目里包含不可下发的对象,请重新选择真实线程。";
case "GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS":
return "至少选择 2 个真实线程后才能修复群成员。";
case "PROJECT_NOT_FOUND":
return "当前群聊不存在或已被删除。";
case "PROJECT_NOT_GROUP_CHAT":
return "当前项目不是群聊,无法修复群成员。";
default:
return error.message;
}
}
export async function GET(
@@ -130,7 +36,7 @@ export async function GET(
const { projectId } = await context.params;
const state = await readState();
const payload = buildParticipantsPayload(state, projectId);
const payload = buildProjectParticipantsPayload(state, projectId);
if (!payload) {
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
@@ -162,14 +68,14 @@ export async function POST(
requestedBy: session.account,
});
const nextState = await readState();
const payload = buildParticipantsPayload(nextState, projectId);
const payload = buildProjectParticipantsPayload(nextState, projectId);
if (!payload) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ ok: false, message: mapParticipantsRepairErrorMessage(error) },
{ status: 400 },
);
}

View File

@@ -10,6 +10,7 @@ import {
updateUserMasterPrompt,
} from "@/lib/boss-data";
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
import { jsonNoStore } from "@/lib/api-response";
export async function GET(
@@ -27,11 +28,12 @@ export async function GET(
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([
const [promptPolicy, userPrompt, projectControls, clawAvailability, hermesAvailability] = await Promise.all([
getMasterAgentPromptPolicy(),
getUserMasterPrompt(session.account),
getProjectAgentControls(projectId, session.account),
getClawBackendAvailability(),
getHermesBackendAvailability(),
]);
return jsonNoStore({
@@ -42,6 +44,7 @@ export async function GET(
projectControls,
projectPromptOverride: projectControls?.promptOverride ?? null,
clawAvailability,
hermesAvailability,
account: session.account,
});
}
@@ -104,6 +107,7 @@ export async function POST(
&& typeof payload.backendOverride === "string"
&& payload.backendOverride.trim() !== ""
&& payload.backendOverride.trim() !== "claw-runtime"
&& payload.backendOverride.trim() !== "hermes-runtime"
) {
return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 });
}
@@ -127,6 +131,24 @@ export async function POST(
}
}
if (
hasBackendOverride &&
typeof payload.backendOverride === "string" &&
payload.backendOverride.trim() === "hermes-runtime"
) {
const hermesAvailability = await getHermesBackendAvailability();
if (!hermesAvailability.selectable) {
return NextResponse.json(
{
ok: false,
message: hermesAvailability.reasonLabel,
hermesAvailability,
},
{ status: 400 },
);
}
}
if (hasUserPromptContent) {
const userPromptContent = typeof payload.userPromptContent === "string" ? payload.userPromptContent.trim() : "";
if (userPromptContent) {
@@ -143,11 +165,12 @@ export async function POST(
}, session.account);
}
const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([
const [promptPolicy, userPrompt, projectControls, clawAvailability, hermesAvailability] = await Promise.all([
getMasterAgentPromptPolicy(),
getUserMasterPrompt(session.account),
getProjectAgentControls(projectId, session.account),
getClawBackendAvailability(),
getHermesBackendAvailability(),
]);
return NextResponse.json({
@@ -158,6 +181,7 @@ export async function POST(
projectControls,
projectPromptOverride: projectControls?.promptOverride ?? null,
clawAvailability,
hermesAvailability,
account: session.account,
});
} catch (error) {

View File

@@ -15,14 +15,58 @@ import {
import { requirePageSession } from "@/lib/boss-auth";
import {
getProjectOrchestrationBackendState,
listDispatchPlansByProject,
readState,
} from "@/lib/boss-data";
import { resolveDispatchPlanComposerState } from "@/lib/dispatch-plan-ui";
import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
import { getProjectDetailView } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
export const dynamic = "force-dynamic";
function conversationTaskStatusLabel(status: string) {
switch (status) {
case "queued":
return "排队中";
case "running":
return "执行中";
case "completed":
return "已完成";
case "failed":
return "已失败";
default:
return status;
}
}
function dedupeExecutionWarnings<
T extends {
title: string;
summary: string;
taskId: string;
requestMessageId: string;
sessionId?: string;
requestId?: string;
createdAt: string;
},
>(warnings: T[]) {
const warningMap = new Map<string, T>();
for (const warning of warnings) {
const dedupeKey = [
warning.requestMessageId,
warning.taskId,
warning.sessionId ?? "",
warning.requestId ?? "",
warning.title,
warning.summary,
].join("::");
const existing = warningMap.get(dedupeKey);
if (!existing || existing.createdAt < warning.createdAt) {
warningMap.set(dedupeKey, warning);
}
}
return Array.from(warningMap.values()).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
export default async function ProjectChatPage({
params,
}: {
@@ -32,15 +76,16 @@ export default async function ProjectChatPage({
const { projectId } = await params;
const state = await readState();
const detail = getProjectDetailView(state, projectId, session.account);
const dispatchPlanState = detail?.project.isGroup
? resolveDispatchPlanComposerState(await listDispatchPlansByProject(projectId))
: resolveDispatchPlanComposerState([]);
const orchestrationBackendState = detail?.project.isGroup
? await getProjectOrchestrationBackendState(projectId)
: null;
if (!detail) notFound();
const dispatchPlanState = detail.project.isGroup
? resolveDispatchPlanComposerState(detail.dispatchPlans)
: resolveDispatchPlanComposerState([]);
const orchestrationBackendState = detail.project.isGroup
? await getProjectOrchestrationBackendState(projectId)
: null;
return (
<AppShell bottomNav={false}>
<RealtimeRefresh
@@ -89,7 +134,7 @@ export default async function ProjectChatPage({
</div>
) : null}
<div className="pt-3">
<ProjectHeaderActions projectId={detail.project.id} />
<ProjectHeaderActions projectId={detail.project.id} isGroup={detail.project.isGroup} />
</div>
{detail.project.isGroup && orchestrationBackendState ? (
<div className="mt-3">
@@ -99,6 +144,37 @@ export default async function ProjectChatPage({
/>
</div>
) : null}
{detail.project.isGroup && detail.participantsPayload && detail.participantsPayload.repairRequired ? (
<div className="mt-3 rounded-2xl border border-[#FAD7A0] bg-[#FFF7E6] px-4 py-4 text-[13px] leading-6 text-[#8A4B00]">
<div className="text-[14px] font-semibold text-[#6B3A00]"></div>
<div className="mt-2">
{detail.participantsPayload.repairReason || "当前群聊里有失效线程,请先修复群成员。"}
</div>
<div className="mt-1 text-[12px] text-[#A56A1D]">
线 {detail.participantsPayload.validParticipantCount} · {detail.participantsPayload.invalidParticipantCount}
</div>
<div className="mt-3 space-y-2">
{detail.participantsPayload.participants.filter((participant) => participant.status !== "active").map((participant) => (
<div
key={`${participant.projectId}-${participant.threadId}`}
className="rounded-xl bg-white/70 px-3 py-2 text-[12px] leading-5 text-[#6B3A00]"
>
<div className="font-semibold">{participant.threadDisplayName}</div>
<div className="mt-1">
{participant.statusLabel ?? participant.status}
{participant.canOpenProject ? " · 可打开项目" : " · 项目引用已丢失"}
</div>
</div>
))}
</div>
<Link
href={`/conversations/${detail.project.id}/participants`}
className="mt-3 inline-flex h-9 items-center justify-center rounded-full bg-[#F3B24B] px-4 text-[13px] font-semibold text-[#3D2400]"
>
</Link>
</div>
) : null}
<div className="mt-4 space-y-3">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[14px] font-semibold text-[#111111]"> Agent </div>
@@ -172,9 +248,74 @@ export default async function ProjectChatPage({
</div>
</div>
<div className="mt-4 flex-1 space-y-4 overflow-y-auto pb-6">
{detail.project.messages.map((message) => (
<ChatBubble key={message.id} message={message} />
))}
{detail.project.messages.map((message) => {
const messageTask = detail.conversationTasks.find((task) => task.requestMessageId === message.id);
const warningMap = new Map<string, typeof detail.executionWarnings[number]>();
for (const warning of detail.executionWarnings) {
if (warning.requestMessageId !== message.id) continue;
const dedupeKey = [
warning.requestMessageId,
warning.taskId,
warning.sessionId ?? "",
warning.requestId ?? "",
warning.title,
warning.summary,
].join("::");
const existing = warningMap.get(dedupeKey);
if (!existing || existing.createdAt < warning.createdAt) {
warningMap.set(dedupeKey, warning);
}
}
const dedupedWarnings = dedupeExecutionWarnings(Array.from(warningMap.values()));
return (
<div key={message.id} className="space-y-2">
<ChatBubble message={message} />
{messageTask ? (
<div
className="ml-1 max-w-[82%] rounded-2xl border border-[#D8E7DE] bg-[#F7FBF8] px-3 py-2 text-[12px] leading-5 text-[#45604D]"
>
<div className="font-semibold text-[#23412E]">
线 · {conversationTaskStatusLabel(messageTask.status)}
</div>
<div className="mt-1">
{messageTask.sessionId ? `Session ${messageTask.sessionId}` : `Task ${messageTask.taskId}`}
{messageTask.targetThreadId ? ` · 线程 ${messageTask.targetThreadId}` : ""}
</div>
</div>
) : null}
{dedupedWarnings.map((warning) => (
warning.requestMessageId === message.id ? (
<div
key={warning.warningId}
className="ml-1 max-w-[82%] rounded-2xl border border-[#FAD7A0] bg-[#FFF7E6] px-3 py-2 text-[12px] leading-5 text-[#8A4B00]"
>
<div className="font-semibold text-[#6B3A00]">{warning.title}</div>
<div className="mt-1">{warning.summary}</div>
<div className="mt-1 text-[#A56A1D]">
{warning.sessionId ? `Session ${warning.sessionId}` : `Task ${warning.taskId}`} ·{" "}
{formatTimestampLabel(warning.createdAt)}
</div>
</div>
) : null
))}
{dedupedWarnings.length > 1 ? (
<div className="ml-1 max-w-[82%] text-[11px] text-[#A56A1D]">
{dedupedWarnings.length}
</div>
) : null}
</div>
);
})}
{detail.conversationTasks.length ? (
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-3 text-[12px] leading-5 text-[#57606A]">
<div className="font-semibold text-[#111111]">线</div>
<div className="mt-1">
{detail.conversationTasks.length}
{conversationTaskStatusLabel(detail.conversationTasks[0].status)}
{detail.conversationTasks[0].sessionId ? ` · Session ${detail.conversationTasks[0].sessionId}` : ""}
</div>
</div>
) : null}
<div className="rounded-2xl bg-white px-4 py-3 text-[13px] leading-6 text-[#57606A]">
MVP
</div>

View File

@@ -0,0 +1,150 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { RealtimeRefresh } from "@/components/app-runtime";
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
import { GroupParticipantsRepairClient } from "@/components/group-participants-repair-client";
import { requirePageSession } from "@/lib/boss-auth";
import { isDispatchableThreadProject, readState } from "@/lib/boss-data";
import { getProjectDetailView } from "@/lib/boss-projections";
export const dynamic = "force-dynamic";
export default async function ParticipantsPage({
params,
searchParams,
}: {
params: Promise<{ projectId: string }>;
searchParams: Promise<{ repaired?: string | string[] | undefined }>;
}) {
const session = await requirePageSession();
const { projectId } = await params;
const repaired = (await searchParams).repaired;
const state = await readState();
const detail = getProjectDetailView(state, projectId, session.account);
if (!detail) notFound();
const participantsPayload = detail.participantsPayload;
const participants = participantsPayload?.participants ?? [];
const invalidParticipants = participants.filter((participant) => participant.status !== "active");
const availableThreads = state.projects
.filter((project) => project.id !== projectId && isDispatchableThreadProject(project))
.map((project) => ({
projectId: project.id,
threadDisplayName: project.threadMeta.threadDisplayName,
folderName: project.threadMeta.folderName,
}));
const canRepairGroupMembers = availableThreads.length >= 2;
const initialSelectedProjectIds = participants
.filter((participant) => participant.status === "active")
.map((participant) => participant.projectId);
return (
<AppShell bottomNav={false}>
<RealtimeRefresh projectId={projectId} events={["conversation.updated", "project.messages.updated"]} />
<StatusBar />
<PageNav title="成员状态" backHref={`/conversations/${projectId}`} />
<div className="space-y-3 px-[18px] pb-6">
{repaired === "1" ? (
<div className="rounded-2xl border border-[#D8E7DE] bg-[#F7FBF8] px-4 py-4 text-[13px] leading-6 text-[#23412E]">
<div className="text-[14px] font-semibold"></div>
<div className="mt-2">线</div>
</div>
) : null}
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[18px] font-semibold text-[#111111]">{detail.project.name}</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">
{participantsPayload?.threadMeta?.folderName?.trim() || "群聊"} ·
</div>
{participantsPayload ? (
<div className="mt-3 text-[12px] leading-6 text-[#57606A]">
线 {participantsPayload.validParticipantCount} · {participantsPayload.invalidParticipantCount}
</div>
) : (
<div className="mt-3 text-[12px] leading-6 text-[#57606A]"></div>
)}
</div>
{participantsPayload?.repairRequired ? (
<div className="rounded-2xl border border-[#FAD7A0] bg-[#FFF7E6] px-4 py-4 text-[13px] leading-6 text-[#8A4B00]">
<div className="text-[14px] font-semibold text-[#6B3A00]"></div>
<div className="mt-2">
{participantsPayload.repairReason || "当前群聊里有失效线程,请先修复群成员。"}
</div>
</div>
) : null}
{participantsPayload?.repairRequired && canRepairGroupMembers ? (
<GroupParticipantsRepairClient
projectId={projectId}
availableThreads={availableThreads}
initialSelectedProjectIds={initialSelectedProjectIds}
/>
) : null}
{participantsPayload?.repairRequired && !canRepairGroupMembers ? (
<div className="rounded-2xl border border-dashed border-[#D8E7DE] bg-[#F7FBF8] px-4 py-4 text-[13px] leading-6 text-[#45604D]">
<div className="text-[14px] font-semibold text-[#23412E]"></div>
<div className="mt-2">线</div>
</div>
) : null}
{invalidParticipants.length ? (
<div className="rounded-2xl border border-[#FAD7A0] bg-[#FFFDF7] px-4 py-4">
<div className="text-[14px] font-semibold text-[#6B3A00]"></div>
<div className="mt-3 space-y-3">
{invalidParticipants.map((participant) => (
<div
key={`${participant.projectId}-${participant.threadId}`}
className="rounded-2xl border border-[#FBE3B0] bg-white px-3 py-3 text-[13px] leading-6 text-[#6B3A00]"
>
<div className="font-semibold text-[#3D2400]">{participant.threadDisplayName}</div>
<div className="mt-1">{participant.statusLabel ?? participant.status}</div>
<div className="mt-1 text-[12px] text-[#A56A1D]">
{participant.folderName}
{participant.canOpenProject ? " · 可打开项目" : " · 项目引用已丢失"}
</div>
{participant.canOpenProject ? (
<div className="mt-2">
<Link
href={`/conversations/${participant.projectId}`}
className="text-[12px] font-semibold text-[#07C160]"
>
线
</Link>
</div>
) : null}
</div>
))}
</div>
</div>
) : null}
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[14px] font-semibold text-[#111111]"></div>
<div className="mt-3 space-y-3">
{participants.length ? (
participants.map((participant) => (
<div
key={`${participant.projectId}-${participant.threadId}`}
className="rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[13px] leading-6 text-[#111111]"
>
<div className="font-semibold">{participant.threadDisplayName}</div>
<div className="mt-1 text-[#57606A]">{participant.folderName}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{participant.statusLabel ?? participant.status}
{participant.isSourceProject ? " · 当前群聊来源线程" : ""}
</div>
</div>
))
) : (
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
</div>
)}
</div>
</div>
</div>
</AppShell>
);
}

View File

@@ -4,7 +4,7 @@ import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { readState } from "@/lib/boss-data";
import { getProjectDetailView } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
export const dynamic = "force-dynamic";

View File

@@ -4,6 +4,7 @@ import { MasterAgentPromptMemoryClient } from "@/components/master-agent-prompt-
import { requirePageSession } from "@/lib/boss-auth";
import { MASTER_AGENT_CHAT_PAGE_ANCHORS } from "@/lib/master-agent-chat-menu";
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
import {
getMasterAgentPromptPolicy,
getProjectAgentControls,
@@ -15,7 +16,7 @@ export const dynamic = "force-dynamic";
export default async function MasterAgentPromptMemoryPage() {
const session = await requirePageSession();
const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability] =
const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability, hermesAvailability] =
await Promise.all([
getMasterAgentPromptPolicy(),
getUserMasterPrompt(session.account),
@@ -23,6 +24,7 @@ export default async function MasterAgentPromptMemoryPage() {
listUserMasterMemories(session.account, { includeArchived: false, scope: "global" }),
listUserMasterMemories(session.account, { includeArchived: false, scope: "project" }),
getClawBackendAvailability(),
getHermesBackendAvailability(),
]);
return (
@@ -47,6 +49,7 @@ export default async function MasterAgentPromptMemoryPage() {
userPrompt={userPrompt}
projectControls={projectControls}
clawAvailability={clawAvailability}
hermesAvailability={hermesAvailability}
globalMemories={globalMemories}
projectMemories={projectMemories}
anchors={MASTER_AGENT_CHAT_PAGE_ANCHORS}

View File

@@ -3,7 +3,7 @@ import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
import { MasterAgentTakeoverClient } from "@/components/master-agent-takeover-client";
import { requirePageSession } from "@/lib/boss-auth";
import { getProjectAgentControls } from "@/lib/boss-data";
import { formatTimestampLabel } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
export const dynamic = "force-dynamic";

View File

@@ -16,7 +16,7 @@ import {
resolveAliyunQwenModelSelection,
resolveAliyunQwenModelValue,
} from "@/lib/ai-account-models";
import { formatTimestampLabel } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
type AccountDraft = {
label: string;

View File

@@ -11,7 +11,7 @@ import {
planThrottledRefresh,
shouldRefreshRealtimeEvent,
} from "@/lib/realtime-refresh";
import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections";
import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections-shared";
import {
clearNativeSessionSnapshot,
currentAppLocation,

View File

@@ -48,8 +48,8 @@ import type {
UserProfile,
UserSettings,
} from "@/lib/boss-data";
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections";
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections-shared";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
function formatClock(value: string) {
return formatTimestampLabel(value);
@@ -914,7 +914,7 @@ export function ChatBubble({ message }: { message: Message }) {
);
}
export function ProjectHeaderActions({ projectId }: { projectId: string }) {
export function ProjectHeaderActions({ projectId, isGroup = false }: { projectId: string; isGroup?: boolean }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Link
@@ -936,10 +936,10 @@ export function ProjectHeaderActions({ projectId }: { projectId: string }) {
</Link>
<Link
href={`/conversations/${projectId}/thread-status`}
href={isGroup ? `/conversations/${projectId}/participants` : `/conversations/${projectId}/thread-status`}
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
>
线
{isGroup ? "成员状态" : "线程状态"}
</Link>
</div>
);

View File

@@ -0,0 +1,115 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export interface GroupParticipantsRepairTarget {
projectId: string;
threadDisplayName: string;
folderName: string;
}
export function GroupParticipantsRepairClient({
projectId,
availableThreads,
initialSelectedProjectIds,
}: {
projectId: string;
availableThreads: GroupParticipantsRepairTarget[];
initialSelectedProjectIds: string[];
}) {
const router = useRouter();
const [selectedProjectIds, setSelectedProjectIds] = useState(() => new Set(initialSelectedProjectIds));
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const canSubmit = !loading && availableThreads.length >= 2 && selectedProjectIds.size >= 2;
function toggleProject(projectId: string) {
setSelectedProjectIds((current) => {
const next = new Set(current);
if (next.has(projectId)) {
next.delete(projectId);
} else {
next.add(projectId);
}
return next;
});
}
async function submitRepair() {
if (availableThreads.length < 2) {
setMessage("当前没有足够的真实线程可用于修复群成员。");
return;
}
const memberProjectIds = Array.from(selectedProjectIds);
if (memberProjectIds.length < 2) {
setMessage("至少选择 2 个真实线程后才能修复群成员。");
return;
}
setLoading(true);
setMessage("");
try {
const response = await fetch(`/api/v1/projects/${projectId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ memberProjectIds }),
});
const result = (await response.json().catch(() => ({}))) as { ok?: boolean; message?: string };
if (!response.ok || !result.ok) {
setMessage(result.message ?? "修复群成员失败,请稍后重试。");
return;
}
setMessage("已更新群成员。");
router.replace(`/conversations/${projectId}/participants?repaired=1`);
router.refresh();
} catch (error) {
setMessage(error instanceof Error ? error.message : "网络异常,修复群成员失败。");
} finally {
setLoading(false);
}
}
return (
<div className="rounded-2xl border border-[#D8E7DE] bg-[#F7FBF8] px-4 py-4">
<div className="text-[14px] font-semibold text-[#23412E]">线</div>
<div className="mt-1 text-[12px] leading-5 text-[#45604D]">
2 线
</div>
<div className="mt-3 space-y-2">
{availableThreads.length ? (
availableThreads.map((thread) => (
<label
key={thread.projectId}
className="flex items-start gap-3 rounded-2xl bg-white px-3 py-3 text-[13px] leading-5 text-[#111111]"
>
<input
type="checkbox"
className="mt-1"
checked={selectedProjectIds.has(thread.projectId)}
onChange={() => toggleProject(thread.projectId)}
/>
<span>
<span className="block font-semibold">{thread.threadDisplayName}</span>
<span className="mt-1 block text-[12px] text-[#57606A]">{thread.folderName}</span>
</span>
</label>
))
) : (
<div className="rounded-2xl bg-white px-3 py-3 text-[12px] leading-6 text-[#57606A]">
线
</div>
)}
</div>
<button
type="button"
disabled={!canSubmit}
onClick={() => void submitRepair()}
className="mt-3 h-10 w-full rounded-full bg-[#07C160] text-[14px] font-semibold text-white disabled:opacity-60"
>
{loading ? "正在修复…" : "提交修复"}
</button>
{message ? <div className="mt-2 text-[12px] leading-5 text-[#45604D]">{message}</div> : null}
</div>
);
}

View File

@@ -12,7 +12,7 @@ import type {
UserMasterPrompt,
} from "@/lib/boss-data";
import type { MasterAgentChatPageAnchors } from "@/lib/master-agent-chat-menu";
import { formatTimestampLabel } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
type MemoryDraft = {
scope: MasterMemoryScope;
@@ -31,6 +31,13 @@ type ClawAvailability = {
reasonLabel: string;
};
type HermesAvailability = {
status: "disabled" | "misconfigured" | "ready";
selectable: boolean;
reason: string;
reasonLabel: string;
};
const memoryScopeOptions: Array<{ value: MasterMemoryScope; label: string }> = [
{ value: "global", label: "通用记忆" },
{ value: "project", label: "项目记忆" },
@@ -153,6 +160,7 @@ export function MasterAgentPromptMemoryClient({
userPrompt,
projectControls,
clawAvailability,
hermesAvailability,
globalMemories,
projectMemories,
anchors,
@@ -162,6 +170,7 @@ export function MasterAgentPromptMemoryClient({
userPrompt: UserMasterPrompt | null;
projectControls: ProjectAgentControls | null;
clawAvailability: ClawAvailability;
hermesAvailability: HermesAvailability;
globalMemories: MasterAgentMemory[];
projectMemories: MasterAgentMemory[];
anchors: MasterAgentChatPageAnchors;
@@ -175,11 +184,25 @@ export function MasterAgentPromptMemoryClient({
const [reasoningEffortOverride, setReasoningEffortOverride] = useState(
projectControls?.reasoningEffortOverride ?? "",
);
const [fastModelOverride, setFastModelOverride] = useState(projectControls?.fastModelOverride ?? "");
const [fastReasoningEffortOverride, setFastReasoningEffortOverride] = useState(
projectControls?.fastReasoningEffortOverride ?? "",
);
const [smartModelOverride, setSmartModelOverride] = useState(projectControls?.smartModelOverride ?? "");
const [smartReasoningEffortOverride, setSmartReasoningEffortOverride] = useState(
projectControls?.smartReasoningEffortOverride ?? "",
);
const [promptOverride, setPromptOverride] = useState(projectControls?.promptOverride ?? "");
const storedClawOverrideUnavailable =
projectControls?.backendOverride === "claw-runtime" && !clawAvailability.selectable;
const storedHermesOverrideUnavailable =
projectControls?.backendOverride === "hermes-runtime" && !hermesAvailability.selectable;
const [backendOverride, setBackendOverride] = useState(
projectControls?.backendOverride === "claw-runtime" && clawAvailability.selectable ? "claw-runtime" : "",
projectControls?.backendOverride === "claw-runtime" && clawAvailability.selectable
? "claw-runtime"
: projectControls?.backendOverride === "hermes-runtime" && hermesAvailability.selectable
? "hermes-runtime"
: "",
);
const [newMemory, setNewMemory] = useState<MemoryDraft>(makeNewMemoryDraft());
const [memoryDrafts, setMemoryDrafts] = useState<Record<string, MemoryDraft>>(() => {
@@ -200,10 +223,19 @@ export function MasterAgentPromptMemoryClient({
? `【执行后端】\n${backendOverride.trim()}`
: storedClawOverrideUnavailable
? "【执行后端】\n默认Claw Runtime 当前不可用,运行时会自动回退)"
: storedHermesOverrideUnavailable
? "【执行后端】\n默认Hermes Runtime 当前不可用,运行时会自动回退)"
: null,
].filter(Boolean);
return sections.length > 0 ? sections.join("\n\n") : "当前还没有组合后的提示词内容。";
}, [backendOverride, globalPrompt, promptOverride, storedClawOverrideUnavailable, userPromptContent]);
}, [
backendOverride,
globalPrompt,
promptOverride,
storedClawOverrideUnavailable,
storedHermesOverrideUnavailable,
userPromptContent,
]);
function updateMemoryDraft(memoryId: string, updater: (draft: MemoryDraft) => MemoryDraft) {
setMemoryDrafts((current) => ({
@@ -264,6 +296,10 @@ export function MasterAgentPromptMemoryClient({
body: JSON.stringify({
modelOverride: modelOverride.trim() || null,
reasoningEffortOverride: reasoningEffortOverride.trim() || null,
fastModelOverride: fastModelOverride.trim() || null,
fastReasoningEffortOverride: fastReasoningEffortOverride.trim() || null,
smartModelOverride: smartModelOverride.trim() || null,
smartReasoningEffortOverride: smartReasoningEffortOverride.trim() || null,
promptOverride: promptOverride.trim() || null,
backendOverride: backendOverride.trim() || null,
}),
@@ -432,6 +468,7 @@ export function MasterAgentPromptMemoryClient({
>
<option value=""></option>
<option value="gpt-5.4">gpt-5.4</option>
<option value="gpt-5.4-mini">gpt-5.4-mini</option>
<option value="gpt-4.1">gpt-4.1</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
</select>
@@ -458,6 +495,63 @@ export function MasterAgentPromptMemoryClient({
>
<option value=""></option>
{clawAvailability.selectable ? <option value="claw-runtime">Claw Runtime</option> : null}
{hermesAvailability.selectable ? <option value="hermes-runtime">Hermes Runtime</option> : null}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={fastModelOverride}
onChange={(event) => setFastModelOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="gpt-5.4-mini">gpt-5.4-mini</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
<option value="gpt-4.1">gpt-4.1</option>
<option value="gpt-5.4">gpt-5.4</option>
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={fastReasoningEffortOverride}
onChange={(event) => setFastReasoningEffortOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={smartModelOverride}
onChange={(event) => setSmartModelOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="gpt-5.4">gpt-5.4</option>
<option value="gpt-4.1">gpt-4.1</option>
<option value="gpt-5.4-mini">gpt-5.4-mini</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={smartReasoningEffortOverride}
onChange={(event) => setSmartReasoningEffortOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
</select>
</label>
</div>
@@ -472,6 +566,17 @@ export function MasterAgentPromptMemoryClient({
) : null}
</div>
) : null}
{!hermesAvailability.selectable ? (
<div className="rounded-2xl border border-[#F4C7C3] bg-[#FFF7F6] px-4 py-3 text-[12px] leading-6 text-[#B54708]">
<div className="font-semibold text-[#912018]">Hermes Runtime </div>
<div>{hermesAvailability.reasonLabel}</div>
{storedHermesOverrideUnavailable ? (
<div className="mt-1 text-[#912018]">
Hermes Runtime退
</div>
) : null}
</div>
) : null}
<TextArea
label="当前对话附加提示词"
value={promptOverride}

View File

@@ -380,6 +380,35 @@ export interface DispatchExecution {
completedByDeviceId?: string;
}
export interface DispatchPlanWithExecutions extends DispatchPlan {
executions: DispatchExecution[];
}
export interface ConversationParticipant {
projectId: string;
deviceId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
avatar: string;
isSourceProject: boolean;
status: "active" | "missing_project" | "invalid_target";
statusLabel?: string;
canOpenProject: boolean;
}
export interface ProjectParticipantsPayload {
ok: true;
projectId: string;
isGroup: boolean;
threadMeta: Project["threadMeta"];
participants: ConversationParticipant[];
repairRequired: boolean;
repairReason?: string;
validParticipantCount: number;
invalidParticipantCount: number;
}
export function buildCollaborationGate(
project?: Pick<Project, "isGroup" | "collaborationMode" | "approvalState" | "lightDispatchReminderEnabled">,
) {
@@ -405,8 +434,12 @@ export function buildCollaborationGate(
export interface ProjectAgentControls {
modelOverride?: string;
reasoningEffortOverride?: ReasoningEffort;
fastModelOverride?: string;
fastReasoningEffortOverride?: ReasoningEffort;
smartModelOverride?: string;
smartReasoningEffortOverride?: ReasoningEffort;
promptOverride?: string;
backendOverride?: "claw-runtime";
backendOverride?: "claw-runtime" | "hermes-runtime";
takeoverEnabled?: boolean;
globalTakeoverEnabled?: boolean;
effectiveTakeoverEnabled?: boolean;
@@ -596,6 +629,20 @@ export interface ThreadProgressEvent {
sourceMessageId?: string;
}
export interface ThreadExecutionWarning {
warningId: string;
taskId: string;
requestMessageId: string;
projectId: string;
targetProjectId?: string;
targetThreadId?: string;
sessionId?: string;
requestId?: string;
title: string;
summary: string;
createdAt: string;
}
export interface VerificationCode {
id: string;
account: string;
@@ -740,6 +787,8 @@ export interface MasterAgentTask {
requestMessageId: string;
requestText: string;
executionPrompt: string;
executionModel?: string;
executionReasoningEffort?: ReasoningEffort;
requestedBy: string;
requestedByAccount: string;
deviceId: string;
@@ -771,6 +820,7 @@ export interface MasterAgentTask {
replyBody?: string;
errorMessage?: string;
requestId?: string;
sessionId?: string;
}
export interface OtaUpdate {
@@ -1019,6 +1069,7 @@ export interface BossState {
deviceImportResolutions: DeviceImportResolution[];
threadStatusDocuments: ThreadStatusDocument[];
threadProgressEvents: ThreadProgressEvent[];
threadExecutionWarnings: ThreadExecutionWarning[];
otaUpdates: OtaUpdate[];
otaUpdateLogs: OtaUpdateLog[];
deviceSkills: DeviceSkill[];
@@ -1663,6 +1714,7 @@ const initialState: BossState = {
projectExecutionPolicies: [],
threadStatusDocuments: [],
threadProgressEvents: [],
threadExecutionWarnings: [],
};
const levelPriority: Record<ContextBudgetLevel, number> = {
@@ -1876,14 +1928,19 @@ function parseReasoningEffortOverride(value: unknown) {
return { kind: "set" as const, value };
}
function parseBackendOverride(value: unknown) {
function parseBackendOverride(
value: unknown,
):
| { kind: "clear" }
| { kind: "invalid" }
| { kind: "set"; value: NonNullable<ProjectAgentControls["backendOverride"]> } {
if (value === undefined || value === null) {
return { kind: "clear" as const };
}
if (value !== "claw-runtime") {
return { kind: "invalid" as const };
if (value === "claw-runtime" || value === "hermes-runtime") {
return { kind: "set" as const, value };
}
return { kind: "set" as const, value: "claw-runtime" as const };
return { kind: "invalid" as const };
}
function parseBooleanControlOverride(value: unknown) {
@@ -2352,8 +2409,19 @@ function normalizeProjectAgentControls(
const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride)
? raw.reasoningEffortOverride
: undefined;
const fastModelOverride = trimToDefined(raw?.fastModelOverride);
const fastReasoningEffortOverride = isReasoningEffort(raw?.fastReasoningEffortOverride)
? raw.fastReasoningEffortOverride
: undefined;
const smartModelOverride = trimToDefined(raw?.smartModelOverride);
const smartReasoningEffortOverride = isReasoningEffort(raw?.smartReasoningEffortOverride)
? raw.smartReasoningEffortOverride
: undefined;
const promptOverride = trimToDefined(raw?.promptOverride);
const backendOverride = raw?.backendOverride === "claw-runtime" ? raw.backendOverride : undefined;
const backendOverride =
raw?.backendOverride === "claw-runtime" || raw?.backendOverride === "hermes-runtime"
? raw.backendOverride
: undefined;
const takeoverEnabled = typeof raw?.takeoverEnabled === "boolean" ? raw.takeoverEnabled : undefined;
const globalTakeoverEnabled =
typeof raw?.globalTakeoverEnabled === "boolean" ? raw.globalTakeoverEnabled : undefined;
@@ -2361,6 +2429,10 @@ function normalizeProjectAgentControls(
if (
!modelOverride &&
!reasoningEffortOverride &&
!fastModelOverride &&
!fastReasoningEffortOverride &&
!smartModelOverride &&
!smartReasoningEffortOverride &&
!promptOverride &&
!backendOverride &&
takeoverEnabled === undefined &&
@@ -2372,6 +2444,10 @@ function normalizeProjectAgentControls(
return {
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
promptOverride,
backendOverride,
takeoverEnabled,
@@ -3060,6 +3136,43 @@ function normalizeThreadProgressEvent(
};
}
function normalizeThreadExecutionWarning(
raw: Partial<ThreadExecutionWarning>,
fallback?: ThreadExecutionWarning,
): ThreadExecutionWarning {
return {
warningId: raw.warningId ?? fallback?.warningId ?? randomToken("thread-warning"),
taskId: trimToDefined(raw.taskId ?? fallback?.taskId) ?? "",
requestMessageId: trimToDefined(raw.requestMessageId ?? fallback?.requestMessageId) ?? "",
projectId: trimToDefined(raw.projectId ?? fallback?.projectId) ?? "",
targetProjectId: trimToDefined(raw.targetProjectId ?? fallback?.targetProjectId),
targetThreadId: trimToDefined(raw.targetThreadId ?? fallback?.targetThreadId),
sessionId: trimToDefined(raw.sessionId ?? fallback?.sessionId),
requestId: trimToDefined(raw.requestId ?? fallback?.requestId),
title: trimToDefined(raw.title ?? fallback?.title) ?? "线程执行告警",
summary: trimToDefined(raw.summary ?? fallback?.summary) ?? "",
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
};
}
function compareThreadExecutionWarnings(a: ThreadExecutionWarning, b: ThreadExecutionWarning) {
const createdDelta = messageTimeValue(b.createdAt) - messageTimeValue(a.createdAt);
if (createdDelta !== 0) return createdDelta;
return b.warningId.localeCompare(a.warningId);
}
function appendThreadExecutionWarningInState(
state: BossState,
input: Omit<ThreadExecutionWarning, "warningId">,
) {
const warning = normalizeThreadExecutionWarning({
warningId: randomToken("thread-warning"),
...input,
});
state.threadExecutionWarnings.unshift(warning);
return warning;
}
function buildHeartbeatProgressSummary(threadDisplayName: string) {
return `检测到线程有新活动:${threadDisplayName}`;
}
@@ -3237,6 +3350,16 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
requestMessageId: task.requestMessageId ?? "",
requestText: task.requestText ?? "",
executionPrompt: task.executionPrompt ?? task.requestText ?? "",
executionModel:
typeof task.executionModel === "string" && task.executionModel.trim()
? task.executionModel.trim()
: undefined,
executionReasoningEffort:
task.executionReasoningEffort === "low" ||
task.executionReasoningEffort === "medium" ||
task.executionReasoningEffort === "high"
? task.executionReasoningEffort
: undefined,
requestedBy: task.requestedBy ?? "用户",
requestedByAccount: task.requestedByAccount ?? "",
deviceId: task.deviceId ?? PRIMARY_CODEX_NODE_ID,
@@ -3272,6 +3395,7 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
replyBody: task.replyBody,
errorMessage: task.errorMessage,
requestId: task.requestId,
sessionId: task.sessionId,
})),
dispatchPlans: ensureArray(raw.dispatchPlans, base.dispatchPlans).map((plan, index) =>
normalizeDispatchPlan(plan, base.dispatchPlans[index % Math.max(1, base.dispatchPlans.length)]),
@@ -3315,6 +3439,15 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
base.threadProgressEvents[index % Math.max(1, base.threadProgressEvents.length)],
),
),
threadExecutionWarnings: ensureArray(
raw.threadExecutionWarnings as Partial<ThreadExecutionWarning>[] | undefined,
base.threadExecutionWarnings,
).map((warning, index) =>
normalizeThreadExecutionWarning(
warning,
base.threadExecutionWarnings[index % Math.max(1, base.threadExecutionWarnings.length)],
),
),
otaUpdates: ensureArray(raw.otaUpdates, base.otaUpdates).map((update, index) => ({
...base.otaUpdates[index % base.otaUpdates.length],
...update,
@@ -3455,9 +3588,6 @@ function removeLegacyBossConsoleArtifacts(state: BossState) {
...device,
projects: device.projects.filter((project) => !isLegacyBossConsoleRef(project)),
}));
state.masterAgentMemories = state.masterAgentMemories.filter(
(memory) => !isLegacyBossConsoleRef(memory.projectId),
);
state.userProjectAgentControls = state.userProjectAgentControls.filter(
(item) => !isLegacyBossConsoleRef(item.projectId),
);
@@ -3968,6 +4098,11 @@ function syncDerivedState(input: BossState) {
return true;
})
.slice(0, 400);
state.threadExecutionWarnings = state.threadExecutionWarnings
.map((warning) => normalizeThreadExecutionWarning(warning))
.filter((warning) => visibleProjectIds.has(warning.projectId))
.sort(compareThreadExecutionWarnings)
.slice(0, 400);
state.deviceSkills = state.deviceSkills
.filter((skill) => visibleDeviceIds.has(skill.deviceId))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -4260,6 +4395,10 @@ export async function updateProjectAgentControls(
payload: {
modelOverride?: unknown;
reasoningEffortOverride?: unknown;
fastModelOverride?: unknown;
fastReasoningEffortOverride?: unknown;
smartModelOverride?: unknown;
smartReasoningEffortOverride?: unknown;
promptOverride?: unknown;
backendOverride?: unknown;
takeoverEnabled?: unknown;
@@ -4281,6 +4420,18 @@ export async function updateProjectAgentControls(
const promptOverrideInput = Object.prototype.hasOwnProperty.call(payload, "promptOverride")
? parseControlTextOverride(payload.promptOverride)
: { kind: "preserve" as const };
const fastModelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "fastModelOverride")
? parseControlTextOverride(payload.fastModelOverride)
: { kind: "preserve" as const };
const fastReasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "fastReasoningEffortOverride")
? parseReasoningEffortOverride(payload.fastReasoningEffortOverride)
: { kind: "preserve" as const };
const smartModelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "smartModelOverride")
? parseControlTextOverride(payload.smartModelOverride)
: { kind: "preserve" as const };
const smartReasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "smartReasoningEffortOverride")
? parseReasoningEffortOverride(payload.smartReasoningEffortOverride)
: { kind: "preserve" as const };
const backendOverrideInput = Object.prototype.hasOwnProperty.call(payload, "backendOverride")
? parseBackendOverride(payload.backendOverride)
: { kind: "preserve" as const };
@@ -4299,6 +4450,18 @@ export async function updateProjectAgentControls(
if (promptOverrideInput.kind === "invalid") {
throw new Error("INVALID_PROMPT_OVERRIDE");
}
if (fastModelOverrideInput.kind === "invalid") {
throw new Error("INVALID_FAST_MODEL_OVERRIDE");
}
if (fastReasoningEffortInput.kind === "invalid") {
throw new Error("INVALID_FAST_REASONING_EFFORT_OVERRIDE");
}
if (smartModelOverrideInput.kind === "invalid") {
throw new Error("INVALID_SMART_MODEL_OVERRIDE");
}
if (smartReasoningEffortInput.kind === "invalid") {
throw new Error("INVALID_SMART_REASONING_EFFORT_OVERRIDE");
}
if (backendOverrideInput.kind === "invalid") {
throw new Error("INVALID_BACKEND_OVERRIDE");
}
@@ -4308,16 +4471,34 @@ export async function updateProjectAgentControls(
if (globalTakeoverEnabledInput.kind === "invalid") {
throw new Error("INVALID_GLOBAL_TAKEOVER_ENABLED");
}
const persistedState = await readState();
const persistedProject = persistedState.projects.find((item) => item.id === projectId);
const allowsThreadBackendOverride =
persistedProject?.id !== undefined && persistedProject.id !== "master-agent" && !persistedProject.isGroup;
if (projectId !== "master-agent") {
if (
modelOverrideInput.kind !== "preserve" ||
reasoningEffortInput.kind !== "preserve" ||
fastModelOverrideInput.kind !== "preserve" ||
fastReasoningEffortInput.kind !== "preserve" ||
smartModelOverrideInput.kind !== "preserve" ||
smartReasoningEffortInput.kind !== "preserve" ||
promptOverrideInput.kind !== "preserve" ||
backendOverrideInput.kind !== "preserve" ||
globalTakeoverEnabledInput.kind !== "preserve"
) {
throw new Error("PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED");
}
if (!allowsThreadBackendOverride && backendOverrideInput.kind !== "preserve") {
throw new Error("PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED");
}
if (
allowsThreadBackendOverride &&
backendOverrideInput.kind === "set" &&
backendOverrideInput.value !== "hermes-runtime"
) {
throw new Error("PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED");
}
} else if (takeoverEnabledInput.kind !== "preserve") {
throw new Error("MASTER_AGENT_TAKEOVER_SCOPE_RESTRICTED");
}
@@ -4347,6 +4528,30 @@ export async function updateProjectAgentControls(
: promptOverrideInput.kind === "clear"
? undefined
: currentControls?.promptOverride;
const fastModelOverride =
fastModelOverrideInput.kind === "set"
? fastModelOverrideInput.value
: fastModelOverrideInput.kind === "clear"
? undefined
: currentControls?.fastModelOverride;
const fastReasoningEffortOverride =
fastReasoningEffortInput.kind === "set"
? fastReasoningEffortInput.value
: fastReasoningEffortInput.kind === "clear"
? undefined
: currentControls?.fastReasoningEffortOverride;
const smartModelOverride =
smartModelOverrideInput.kind === "set"
? smartModelOverrideInput.value
: smartModelOverrideInput.kind === "clear"
? undefined
: currentControls?.smartModelOverride;
const smartReasoningEffortOverride =
smartReasoningEffortInput.kind === "set"
? smartReasoningEffortInput.value
: smartReasoningEffortInput.kind === "clear"
? undefined
: currentControls?.smartReasoningEffortOverride;
const backendOverride =
backendOverrideInput.kind === "set"
? backendOverrideInput.value
@@ -4369,12 +4574,20 @@ export async function updateProjectAgentControls(
const currentModelOverride = currentControls?.modelOverride;
const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride;
const currentPromptOverride = currentControls?.promptOverride;
const currentFastModelOverride = currentControls?.fastModelOverride;
const currentFastReasoningEffortOverride = currentControls?.fastReasoningEffortOverride;
const currentSmartModelOverride = currentControls?.smartModelOverride;
const currentSmartReasoningEffortOverride = currentControls?.smartReasoningEffortOverride;
const currentBackendOverride = currentControls?.backendOverride;
const currentTakeoverEnabled = currentControls?.takeoverEnabled;
const currentGlobalTakeoverEnabled = currentControls?.globalTakeoverEnabled;
if (
currentModelOverride === modelOverride &&
currentReasoningEffortOverride === reasoningEffortOverride &&
currentFastModelOverride === fastModelOverride &&
currentFastReasoningEffortOverride === fastReasoningEffortOverride &&
currentSmartModelOverride === smartModelOverride &&
currentSmartReasoningEffortOverride === smartReasoningEffortOverride &&
currentPromptOverride === promptOverride &&
currentBackendOverride === backendOverride &&
currentTakeoverEnabled === takeoverEnabled &&
@@ -4394,6 +4607,10 @@ export async function updateProjectAgentControls(
const nextControls = {
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
promptOverride,
backendOverride,
takeoverEnabled,
@@ -5710,6 +5927,8 @@ export async function queueMasterAgentTask(payload: {
requestMessageId: string;
requestText: string;
executionPrompt: string;
executionModel?: string;
executionReasoningEffort?: ReasoningEffort;
requestedBy: string;
requestedByAccount: string;
deviceId: string;
@@ -5734,6 +5953,7 @@ export async function queueMasterAgentTask(payload: {
deviceImportCandidateFolderName?: string;
projectUnderstandingTargetProjectId?: string;
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
sessionId?: string;
}) {
const task = await mutateState((state) => {
const task: MasterAgentTask = {
@@ -5743,6 +5963,8 @@ export async function queueMasterAgentTask(payload: {
requestMessageId: payload.requestMessageId,
requestText: payload.requestText,
executionPrompt: payload.executionPrompt,
executionModel: payload.executionModel?.trim() || undefined,
executionReasoningEffort: payload.executionReasoningEffort,
requestedBy: payload.requestedBy,
requestedByAccount: payload.requestedByAccount,
deviceId: payload.deviceId,
@@ -5767,6 +5989,7 @@ export async function queueMasterAgentTask(payload: {
deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName,
projectUnderstandingTargetProjectId: payload.projectUnderstandingTargetProjectId,
projectUnderstandingReason: payload.projectUnderstandingReason,
sessionId: payload.sessionId,
status: "queued",
requestedAt: nowIso(),
};
@@ -5886,12 +6109,111 @@ function upsertDispatchPlanInState(
return plan;
}
export async function listDispatchPlansByProject(groupProjectId: string) {
const state = await readState();
export function buildDispatchPlansByProject(state: BossState, groupProjectId: string): DispatchPlanWithExecutions[] {
const normalizedGroupProjectId = groupProjectId.trim();
return state.dispatchPlans
.filter((plan) => plan.groupProjectId === normalizedGroupProjectId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((plan) => ({
...plan,
executions: state.dispatchExecutions
.filter((execution) => execution.planId === plan.planId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((execution) => ({ ...execution })),
}));
}
function buildParticipantAvatar(label: string, avatar?: string) {
const normalizedAvatar = avatar?.trim();
if (normalizedAvatar) {
return normalizedAvatar;
}
const trimmed = label.trim();
if (!trimmed) return "A";
return trimmed.slice(0, 1).toUpperCase();
}
export function buildProjectParticipantsPayload(
state: BossState,
projectId: string,
): ProjectParticipantsPayload | null {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
}
const participants: ConversationParticipant[] = project.isGroup
? project.groupMembers.map((member) => {
const candidateProject = state.projects.find((item) => item.id === member.projectId);
const device = state.devices.find((item) => item.id === member.deviceId);
const status: ConversationParticipant["status"] = !candidateProject
? "missing_project"
: isDispatchableThreadProject(candidateProject)
? "active"
: "invalid_target";
return {
projectId: member.projectId,
deviceId: member.deviceId,
threadId: member.threadId,
threadDisplayName: member.threadDisplayName,
folderName: member.folderName,
avatar: buildParticipantAvatar(member.threadDisplayName, device?.avatar),
isSourceProject: member.projectId === project.id,
status,
statusLabel:
status === "missing_project"
? "引用已失效"
: status === "invalid_target"
? "不是可下发线程"
: undefined,
canOpenProject: Boolean(candidateProject),
};
})
: [
{
projectId: project.id,
deviceId: project.deviceIds[0] ?? project.id,
threadId: project.threadMeta.threadId,
threadDisplayName: project.threadMeta.threadDisplayName,
folderName: project.threadMeta.folderName,
avatar: buildParticipantAvatar(
project.threadMeta.threadDisplayName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
),
isSourceProject: true,
status: "active",
canOpenProject: true,
},
];
const validParticipantCount = participants.filter((item) => item.status === "active").length;
const invalidParticipantCount = participants.length - validParticipantCount;
const repairRequired =
project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2);
const repairReason = !repairRequired
? undefined
: validParticipantCount === 0
? "当前群聊里还没有可下发的真实线程,请重新添加线程。"
: invalidParticipantCount > 0
? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"
: "当前群聊至少需要 2 个真实线程成员。";
return {
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
repairRequired,
repairReason,
validParticipantCount,
invalidParticipantCount,
};
}
export async function listDispatchPlansByProject(groupProjectId: string) {
const state = await readState();
return buildDispatchPlansByProject(state, groupProjectId);
}
function canOwnDispatchPlans(project: Project) {
@@ -6677,6 +6999,11 @@ export async function completeMasterAgentTask(payload: {
replyBody?: string;
errorMessage?: string;
requestId?: string;
sessionId?: string;
warnings?: Array<{
title: string;
summary: string;
}>;
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
@@ -6703,6 +7030,13 @@ export async function completeMasterAgentTask(payload: {
task.replyBody = payload.replyBody?.trim() || undefined;
task.errorMessage = payload.errorMessage?.trim() || undefined;
task.requestId = payload.requestId;
task.sessionId = payload.sessionId?.trim() || task.sessionId;
const normalizedWarnings = (payload.warnings ?? [])
.map((warning) => ({
title: warning.title.trim(),
summary: warning.summary.trim(),
}))
.filter((warning) => warning.title && warning.summary);
const linkedAccount = task.accountId
? state.aiAccounts.find((item) => item.accountId === task.accountId)
: undefined;
@@ -6948,6 +7282,23 @@ export async function completeMasterAgentTask(payload: {
}
}
if (normalizedWarnings.length > 0) {
for (const warning of normalizedWarnings) {
appendThreadExecutionWarningInState(state, {
taskId: task.taskId,
requestMessageId: task.requestMessageId,
projectId: task.projectId,
targetProjectId: payload.targetProjectId?.trim() || task.targetProjectId,
targetThreadId: payload.targetThreadId?.trim() || task.targetThreadId,
sessionId: payload.sessionId?.trim() || task.sessionId,
requestId: payload.requestId?.trim() || task.requestId,
title: warning.title,
summary: warning.summary,
createdAt: task.completedAt,
});
}
}
return {
...task,
dispatchPlan: createdDispatchPlan ? { ...createdDispatchPlan } : undefined,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
import type {
BossState,
ConversationParticipant,
ContextBudgetLevel,
Device,
DeviceEnrollment,
DeviceImportDraft,
DeviceImportResolution,
DeviceSkill,
DispatchPlanWithExecutions,
Project,
ProjectExecutionPolicy,
ProjectParticipantsPayload,
RiskLevel,
ThreadContextSnapshot,
} from "@/lib/boss-data";
export interface ContextIndicator {
visible: boolean;
style: "ring_percent";
percent?: number;
level?: ContextBudgetLevel;
}
export interface ConversationItem {
conversationId: string;
conversationType: "master_agent" | "single_device" | "group" | "folder_archive";
projectId: string;
projectTitle: string;
threadTitle: string;
folderLabel: string;
folderKey?: string;
threadCount?: number;
searchAliases?: string[];
searchTargetProjectIds?: string[];
preview: string;
lastMessagePreview: string;
activityIconCount: number;
topPinnedLabel?: "置顶";
manualPinned: boolean;
latestReplyAt: string;
latestReplyLabel: string;
unreadCount: number;
riskLevel: RiskLevel;
activeDeviceCount: number;
deviceNamesPreview: string[];
avatar: {
primary: string;
secondary?: string;
overflowCount?: number;
};
groupMembers?: Array<{
threadId: string;
avatar: string;
title: string;
}>;
contextBudgetIndicator: ContextIndicator;
contextBudgetSourceNodeId?: string;
contextBudgetUpdatedAt?: string;
mustFinishBeforeCompaction: boolean;
}
export interface DeviceWorkspaceView {
selectedDevice?: Device;
relatedThreads: ThreadContextSnapshot[];
activeEnrollment?: DeviceEnrollment;
importDraft?: DeviceImportDraft;
importResolution?: DeviceImportResolution;
projectExecutionPolicies?: ProjectExecutionPolicy[];
}
export interface SkillInventoryDeviceGroup {
device: Device;
skills: DeviceSkill[];
}
const shanghaiFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const shanghaiDayFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export function formatTimestampLabel(value?: string, fallback = "刚刚") {
if (!value) return fallback;
if (!value.includes("T")) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diff = Date.now() - date.getTime();
if (Math.abs(diff) < 60_000) return "刚刚";
if (diff >= 0 && diff < 24 * 60 * 60_000) {
return shanghaiFormatter.format(date);
}
return shanghaiDayFormatter.format(date);
}
function isDispatchableThreadProject(project: Project) {
return (
project.id !== "master-agent" &&
!project.isGroup &&
Boolean(project.threadMeta.codexThreadRef?.trim()) &&
project.deviceIds.length > 0
);
}
function buildParticipantAvatar(label: string, avatar?: string) {
const normalizedAvatar = avatar?.trim();
if (normalizedAvatar) {
return normalizedAvatar;
}
const trimmed = label.trim();
if (!trimmed) return "A";
return trimmed.slice(0, 1).toUpperCase();
}
export function buildDispatchPlansByProject(
state: BossState,
groupProjectId: string,
): DispatchPlanWithExecutions[] {
const normalizedGroupProjectId = groupProjectId.trim();
return state.dispatchPlans
.filter((plan) => plan.groupProjectId === normalizedGroupProjectId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((plan) => ({
...plan,
executions: state.dispatchExecutions
.filter((execution) => execution.planId === plan.planId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((execution) => ({ ...execution })),
}));
}
export function buildProjectParticipantsPayload(
state: BossState,
projectId: string,
): ProjectParticipantsPayload | null {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
}
const participants = project.isGroup
? project.groupMembers.map((member) => {
const candidateProject = state.projects.find((item) => item.id === member.projectId);
const device = state.devices.find((item) => item.id === member.deviceId);
const status: ConversationParticipant["status"] = !candidateProject
? "missing_project"
: isDispatchableThreadProject(candidateProject)
? "active"
: "invalid_target";
return {
projectId: member.projectId,
deviceId: member.deviceId,
threadId: member.threadId,
threadDisplayName: member.threadDisplayName,
folderName: member.folderName,
avatar: buildParticipantAvatar(member.threadDisplayName, device?.avatar),
isSourceProject: member.projectId === project.id,
status,
statusLabel:
status === "missing_project"
? "引用已失效"
: status === "invalid_target"
? "不是可下发线程"
: undefined,
canOpenProject: Boolean(candidateProject),
};
})
: [
{
projectId: project.id,
deviceId: project.deviceIds[0] ?? project.id,
threadId: project.threadMeta.threadId,
threadDisplayName: project.threadMeta.threadDisplayName,
folderName: project.threadMeta.folderName,
avatar: buildParticipantAvatar(
project.threadMeta.threadDisplayName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
),
isSourceProject: true,
status: "active" as const,
canOpenProject: true,
},
];
const validParticipantCount = participants.filter((item) => item.status === "active").length;
const invalidParticipantCount = participants.filter((item) => item.status !== "active").length;
const repairRequired =
project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2);
const repairReason = !repairRequired
? undefined
: validParticipantCount === 0
? "当前群聊里还没有可下发的真实线程,请重新添加线程。"
: invalidParticipantCount > 0
? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"
: "当前群聊至少需要 2 个真实线程成员。";
return {
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
repairRequired,
repairReason,
validParticipantCount,
invalidParticipantCount,
};
}

View File

@@ -9,11 +9,7 @@ import type {
Capability,
ContextBudgetLevel,
Device,
DeviceEnrollment,
DeviceImportDraft,
DeviceImportResolution,
ProjectExecutionPolicy,
DeviceSkill,
DispatchPlanWithExecutions,
MasterIdentitySummary,
MasterAgentMemory,
MasterAgentPromptPolicy,
@@ -22,57 +18,29 @@ import type {
OpsRepairVerification,
Project,
ProjectAgentControls,
RiskLevel,
ProjectParticipantsPayload,
ThreadContextAlert,
ThreadContextSnapshot,
ThreadHandoffPackage,
UserMasterPrompt,
MasterAgentTaskStatus,
} from "@/lib/boss-data";
export interface ContextIndicator {
visible: boolean;
style: "ring_percent";
percent?: number;
level?: ContextBudgetLevel;
}
export interface ConversationItem {
conversationId: string;
conversationType: "master_agent" | "single_device" | "group" | "folder_archive";
projectId: string;
projectTitle: string;
threadTitle: string;
folderLabel: string;
folderKey?: string;
threadCount?: number;
searchAliases?: string[];
searchTargetProjectIds?: string[];
preview: string;
lastMessagePreview: string;
activityIconCount: number;
topPinnedLabel?: "置顶";
manualPinned: boolean;
latestReplyAt: string;
latestReplyLabel: string;
unreadCount: number;
riskLevel: RiskLevel;
activeDeviceCount: number;
deviceNamesPreview: string[];
avatar: {
primary: string;
secondary?: string;
overflowCount?: number;
};
groupMembers?: Array<{
threadId: string;
avatar: string;
title: string;
}>;
contextBudgetIndicator: ContextIndicator;
contextBudgetSourceNodeId?: string;
contextBudgetUpdatedAt?: string;
mustFinishBeforeCompaction: boolean;
}
import {
buildDispatchPlansByProject,
buildProjectParticipantsPayload,
formatTimestampLabel,
} from "@/lib/boss-projections-shared";
import type {
ConversationItem,
DeviceWorkspaceView,
SkillInventoryDeviceGroup,
} from "@/lib/boss-projections-shared";
export type {
ContextIndicator,
ConversationItem,
DeviceWorkspaceView,
SkillInventoryDeviceGroup,
} from "@/lib/boss-projections-shared";
export interface ThreadContextView {
snapshot: ThreadContextSnapshot;
@@ -84,6 +52,10 @@ export interface ProjectDetailView {
project: Project;
agentControls?: ProjectAgentControls | null;
devices: Device[];
conversationTasks: ConversationTaskSummary[];
executionWarnings: ExecutionWarningSummary[];
dispatchPlans: DispatchPlanWithExecutions[];
participantsPayload?: ProjectParticipantsPayload | null;
masterIdentity?: MasterIdentitySummary;
activeThreadContexts: ThreadContextView[];
nextCompactionRiskThreadId?: string;
@@ -102,15 +74,6 @@ export interface ThreadContextDetailView {
masterActions: string[];
}
export interface DeviceWorkspaceView {
selectedDevice?: Device;
relatedThreads: ThreadContextSnapshot[];
activeEnrollment?: DeviceEnrollment;
importDraft?: DeviceImportDraft;
importResolution?: DeviceImportResolution;
projectExecutionPolicies?: ProjectExecutionPolicy[];
}
export interface OpsSummaryView {
mode: "active" | "idle";
faults: OpsFault[];
@@ -127,11 +90,6 @@ export interface AuditSummaryView {
capabilities: Capability[];
}
export interface SkillInventoryDeviceGroup {
device: Device;
skills: DeviceSkill[];
}
export interface SkillInventoryView {
boundDeviceId?: string;
groups: SkillInventoryDeviceGroup[];
@@ -150,36 +108,6 @@ const aiRolePriority: Record<AiAccountRole, number> = {
api_fallback: 2,
};
const shanghaiFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const shanghaiDayFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export function formatTimestampLabel(value?: string, fallback = "刚刚") {
if (!value) return fallback;
if (!value.includes("T")) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diff = Date.now() - date.getTime();
if (Math.abs(diff) < 60_000) return "刚刚";
if (diff >= 0 && diff < 24 * 60 * 60_000) {
return shanghaiFormatter.format(date);
}
return shanghaiDayFormatter.format(date);
}
const STALE_CONTEXT_SYNC_LABEL = "待同步";
const STALE_CONTEXT_REPLY_THRESHOLD_MS = 7 * 24 * 60 * 60_000;
@@ -548,10 +476,103 @@ export interface ConversationFolderView {
threads: ConversationItem[];
}
export interface ConversationTaskSummary {
taskId: string;
requestMessageId: string;
status: MasterAgentTaskStatus;
requestId?: string;
sessionId?: string;
targetProjectId?: string;
targetThreadId?: string;
}
export interface ExecutionWarningSummary {
warningId: string;
taskId: string;
requestMessageId: string;
sessionId?: string;
requestId?: string;
targetProjectId?: string;
targetThreadId?: string;
title: string;
summary: string;
createdAt: string;
}
export interface ProjectMessagesRealtimePayload {
ok: true;
project: Project;
devices: Device[];
conversationTasks: ConversationTaskSummary[];
executionWarnings: ExecutionWarningSummary[];
}
function buildProjectConversationTaskSummaries(
state: BossState,
project: Project,
): ConversationTaskSummary[] {
const visibleMessageIds = new Set(
project.messages
.map((message) => message.id?.trim())
.filter((messageId): messageId is string => Boolean(messageId)),
);
if (visibleMessageIds.size === 0) {
return [];
}
return state.masterAgentTasks
.filter(
(task) =>
task.taskType === "conversation_reply" &&
task.projectId === project.id &&
visibleMessageIds.has(task.requestMessageId),
)
.sort((left, right) => right.requestedAt.localeCompare(left.requestedAt))
.map((task) => ({
taskId: task.taskId,
requestMessageId: task.requestMessageId,
status: task.status,
requestId: task.requestId,
sessionId: task.sessionId,
targetProjectId: task.targetProjectId,
targetThreadId: task.targetThreadId,
}));
}
function buildProjectExecutionWarnings(
state: BossState,
project: Project,
): ExecutionWarningSummary[] {
const visibleTaskIds = new Set(
buildProjectConversationTaskSummaries(state, project)
.map((task) => task.taskId.trim())
.filter(Boolean),
);
if (visibleTaskIds.size === 0) {
return [];
}
return state.threadExecutionWarnings
.filter(
(warning) =>
warning.projectId === project.id &&
visibleTaskIds.has(warning.taskId),
)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map((warning) => ({
warningId: warning.warningId,
taskId: warning.taskId,
requestMessageId: warning.requestMessageId,
sessionId: warning.sessionId,
requestId: warning.requestId,
targetProjectId: warning.targetProjectId,
targetThreadId: warning.targetThreadId,
title: warning.title,
summary: warning.summary,
createdAt: warning.createdAt,
}));
}
export function getConversationHomeItems(state: BossState): ConversationItem[] {
@@ -732,6 +753,8 @@ export function buildProjectMessagesRealtimePayload(
ok: true,
project,
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
conversationTasks: buildProjectConversationTaskSummaries(state, project),
executionWarnings: buildProjectExecutionWarnings(state, project),
};
}
@@ -815,6 +838,10 @@ export function getProjectDetailView(state: BossState, projectId: string, accoun
project,
agentControls: resolveProjectAgentControls(state, projectId, account),
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
conversationTasks: buildProjectConversationTaskSummaries(state, project),
executionWarnings: buildProjectExecutionWarnings(state, project),
dispatchPlans: project.isGroup ? buildDispatchPlansByProject(state, projectId) : [],
participantsPayload: project.isGroup ? buildProjectParticipantsPayload(state, projectId) : null,
masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined,
activeThreadContexts,
nextCompactionRiskThreadId: topRisk?.threadId,

View File

@@ -8,6 +8,11 @@ import {
type ClawBackendSelectionState,
isClawRequestKindSupported,
} from "@/lib/execution/backends/claw-backend";
import {
HERMES_BACKEND,
type HermesBackendSelectionState,
isHermesRequestKindSupported,
} from "@/lib/execution/backends/hermes-backend";
import {
MASTER_CODEX_NODE_BACKEND,
isReadyMasterCodexNodeBackend,
@@ -27,10 +32,12 @@ export interface ExecutionBackendSelectionInput {
requestKind?: ExecutionRequestKind;
requestedBackendId?: string;
claw?: ClawBackendSelectionState;
hermes?: HermesBackendSelectionState;
}
export type ExecutionBackendChoice =
| typeof CLAW_BACKEND
| typeof HERMES_BACKEND
| typeof MASTER_CODEX_NODE_BACKEND
| typeof OPENAI_BACKEND
| typeof ALIYUN_QWEN_BACKEND;
@@ -57,6 +64,14 @@ function isReadyBackend(choice: ExecutionBackendChoice, input: ExecutionBackendS
return isClawRequestKindSupported(requestKind);
}
if (choice.backendId === HERMES_BACKEND.backendId) {
const requestKind = input.requestKind;
if (!input.hermes?.selectable || !requestKind) {
return false;
}
return isHermesRequestKindSupported(requestKind);
}
const candidates = [
...(input.primary.provider === choice.provider ? [input.primary] : []),
...input.backups.filter((item) => item.provider === choice.provider),
@@ -104,6 +119,13 @@ export function listExecutionBackendChoices(
pushBackend(CLAW_BACKEND);
}
if (
input.requestedBackendId === HERMES_BACKEND.backendId &&
isReadyBackend(HERMES_BACKEND, input)
) {
pushBackend(HERMES_BACKEND);
}
if (input.primary.status === "ready") {
pushBackend(primaryBackend);
}

View File

@@ -0,0 +1,112 @@
import type { ExecutionBackend } from "@/lib/execution/execution-backend";
import type {
ExecutionBackendDescription,
ExecutionImmediateResult,
ExecutionRequest,
ExecutionRequestKind,
} from "@/lib/execution/types";
import {
getHermesBackendAvailability,
getHermesBackendConfig,
isHermesBackendConfigured,
type HermesBackendAvailability,
type HermesBackendConfig,
} from "@/lib/execution/backends/hermes-config";
import { runHermesCommand } from "@/lib/execution/backends/hermes-runner";
export const HERMES_BACKEND_ID = "hermes-runtime";
export const HERMES_BACKEND = {
backendId: HERMES_BACKEND_ID,
label: "Hermes Runtime",
mode: "local",
} as const satisfies ExecutionBackendDescription;
const SUPPORTED_HERMES_KINDS = new Set<ExecutionRequestKind>([
"master_agent_reply",
"thread_reply",
]);
type HermesRunnerInput = Parameters<typeof runHermesCommand>[0];
type HermesRunner = (input: HermesRunnerInput) => Promise<ExecutionImmediateResult>;
export interface HermesBackendSelectionState {
enabled: boolean;
selectable: boolean;
availability: HermesBackendAvailability;
supportsKinds: ExecutionRequestKind[];
}
function createFailedResult(error: string): ExecutionImmediateResult {
return {
status: "failed",
backendId: HERMES_BACKEND_ID,
error,
};
}
export function isHermesRequestKindSupported(kind: ExecutionRequestKind) {
return SUPPORTED_HERMES_KINDS.has(kind);
}
export async function getHermesBackendSelectionState(
config: HermesBackendConfig = getHermesBackendConfig(),
): Promise<HermesBackendSelectionState> {
const availability = await getHermesBackendAvailability(config);
return {
enabled: isHermesBackendConfigured(config),
selectable: availability.selectable,
availability,
supportsKinds: [...SUPPORTED_HERMES_KINDS],
};
}
function buildHermesPayload(input: ExecutionRequest, config: HermesBackendConfig) {
return {
kind: input.kind,
projectId: input.projectId,
requestMessageId: input.requestMessageId,
body: input.body,
executionPrompt: input.executionPrompt ?? input.body,
model: input.modelOverride ?? config.defaultModel,
reasoningEffort: input.reasoningEffortOverride ?? "medium",
toolsets: config.toolsets,
skills: config.skills,
...(input.targetProjectId ? { targetProjectId: input.targetProjectId } : {}),
...(input.targetThreadId ? { targetThreadId: input.targetThreadId } : {}),
...(input.requestedByAccount ? { requestedByAccount: input.requestedByAccount } : {}),
...(input.requestedByLabel ? { requestedByLabel: input.requestedByLabel } : {}),
...(input.taskId ? { taskId: input.taskId } : {}),
};
}
export function createHermesBackend(options?: {
config?: HermesBackendConfig;
runner?: HermesRunner;
}): ExecutionBackend {
const config = options?.config ?? getHermesBackendConfig();
const runner = options?.runner ?? runHermesCommand;
return {
backendId: HERMES_BACKEND_ID,
async canHandle(input) {
return isHermesBackendConfigured(config) && isHermesRequestKindSupported(input.kind);
},
async execute(input) {
const canHandle = await this.canHandle(input);
if (!canHandle) {
return createFailedResult("HERMES_BACKEND_NOT_AVAILABLE");
}
return runner({
config,
payload: buildHermesPayload(input, config),
});
},
async describe() {
return HERMES_BACKEND;
},
};
}
export const HERMES_BACKEND_ADAPTER = createHermesBackend();
export const createHermesBackendForTesting = createHermesBackend;

View File

@@ -0,0 +1,212 @@
import { constants } from "node:fs";
import { access } from "node:fs/promises";
import path from "node:path";
export interface HermesBackendConfig {
enabled: boolean;
command: string;
args: string[];
cwd?: string;
timeoutMs: number;
defaultModel?: string;
toolsets: string[];
skills: string[];
sourceTag: string;
}
export type HermesBackendAvailabilityStatus = "disabled" | "misconfigured" | "ready";
export interface HermesBackendAvailability {
status: HermesBackendAvailabilityStatus;
selectable: boolean;
configured: boolean;
reason:
| "disabled"
| "command_not_set"
| "command_not_found"
| "workdir_not_found"
| "script_not_found"
| "ready";
reasonLabel: string;
command?: string;
cwd?: string;
}
function parseBoolean(value: string | undefined) {
return value?.trim().toLowerCase() === "true";
}
function parseArgs(value: string | undefined) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseCsv(value: string | undefined) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseTimeoutMs(value: string | undefined) {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120000;
}
export function getHermesBackendConfig(): HermesBackendConfig {
return {
enabled: parseBoolean(process.env.BOSS_HERMES_ENABLED),
command: process.env.BOSS_HERMES_COMMAND?.trim() || "hermes",
args: parseArgs(process.env.BOSS_HERMES_ARGS),
cwd: process.env.BOSS_HERMES_WORKDIR?.trim() || undefined,
timeoutMs: parseTimeoutMs(process.env.BOSS_HERMES_TIMEOUT_MS),
defaultModel: process.env.BOSS_HERMES_DEFAULT_MODEL?.trim() || undefined,
toolsets: parseCsv(process.env.BOSS_HERMES_TOOLSETS),
skills: parseCsv(process.env.BOSS_HERMES_SKILLS),
sourceTag: process.env.BOSS_HERMES_SOURCE_TAG?.trim() || "tool",
};
}
export function isHermesBackendConfigured(config: HermesBackendConfig) {
return config.enabled && Boolean(config.command);
}
function commandLooksLikePath(command: string) {
return command.includes("/") || command.includes("\\");
}
async function fileExists(filePath: string, mode = constants.F_OK) {
try {
await access(filePath, mode);
return true;
} catch {
return false;
}
}
function resolveScriptCandidate(config: HermesBackendConfig) {
if (!config.command || config.args.length === 0) {
return null;
}
const commandName = path.basename(config.command).toLowerCase();
const scriptRuntimes = new Set([
"node",
"node.exe",
"tsx",
"tsx.cmd",
"bun",
"bun.exe",
"deno",
"deno.exe",
"python",
"python.exe",
"python3",
"python3.exe",
]);
if (!scriptRuntimes.has(commandName)) {
return null;
}
const candidate = config.args[0];
if (!candidate || candidate.startsWith("-")) {
return null;
}
return path.isAbsolute(candidate)
? candidate
: path.resolve(config.cwd?.trim() || process.cwd(), candidate);
}
async function isCommandReachable(command: string) {
if (commandLooksLikePath(command)) {
return fileExists(path.resolve(command), constants.X_OK);
}
const searchPaths = (process.env.PATH || "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
for (const entry of searchPaths) {
const candidate = path.join(entry, command);
if (await fileExists(candidate, constants.X_OK)) {
return true;
}
}
return false;
}
export async function getHermesBackendAvailability(
config: HermesBackendConfig = getHermesBackendConfig(),
): Promise<HermesBackendAvailability> {
const base = {
command: config.command,
cwd: config.cwd,
configured: isHermesBackendConfigured(config),
};
if (!config.enabled) {
return {
...base,
status: "disabled",
selectable: false,
reason: "disabled",
reasonLabel: "Hermes Runtime 当前未启用。",
};
}
if (!config.command) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "command_not_set",
reasonLabel: "Hermes Runtime 缺少启动命令。",
};
}
if (!(await isCommandReachable(config.command))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "command_not_found",
reasonLabel: "未检测到可执行的 Hermes 启动命令。",
};
}
if (config.cwd && !(await fileExists(config.cwd, constants.F_OK))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "workdir_not_found",
reasonLabel: "Hermes Runtime 工作目录不存在。",
};
}
const scriptCandidate = resolveScriptCandidate(config);
if (scriptCandidate && !(await fileExists(scriptCandidate, constants.F_OK))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "script_not_found",
reasonLabel: "未检测到有效的 Hermes 启动脚本,将自动回退到默认后端。",
};
}
return {
...base,
status: "ready",
selectable: true,
reason: "ready",
reasonLabel: "Hermes Runtime 可用。",
};
}
export const getHermesBackendConfigForTesting = getHermesBackendConfig;
export const isHermesBackendConfiguredForTesting = isHermesBackendConfigured;
export const getHermesBackendAvailabilityForTesting = getHermesBackendAvailability;

View File

@@ -0,0 +1,152 @@
import { spawn } from "node:child_process";
import type { HermesBackendConfig } from "@/lib/execution/backends/hermes-config";
import type { ExecutionImmediateResult } from "@/lib/execution/types";
const HERMES_BACKEND_ID = "hermes-runtime";
function createFailedResult(error: string): ExecutionImmediateResult {
return {
status: "failed",
backendId: HERMES_BACKEND_ID,
error,
};
}
function trimHermesQuietFooter(stdout: string) {
return stdout
.split(/\r?\n/)
.filter((line) => !/^session_id:\s*\S+/i.test(line.trim()))
.join("\n")
.trim();
}
function extractHermesSessionId(stdout: string) {
for (const line of stdout.split(/\r?\n/)) {
const match = line.trim().match(/^session_id:\s*(\S+)/i);
if (match?.[1]) {
return match[1];
}
}
return undefined;
}
function normalizeHermesProcessResult(input: {
exitCode: number;
stdout: string;
stderr: string;
}): ExecutionImmediateResult {
if (input.exitCode !== 0) {
return createFailedResult(input.stderr.trim() || `HERMES_EXIT_${input.exitCode}`);
}
const output = trimHermesQuietFooter(input.stdout);
const sessionId = extractHermesSessionId(input.stdout);
if (!output) {
return createFailedResult("EMPTY_HERMES_RESPONSE");
}
return {
status: "completed",
backendId: HERMES_BACKEND_ID,
output,
sessionId,
};
}
function buildHermesArgs(config: HermesBackendConfig, payload: Record<string, unknown>) {
const executionPrompt =
typeof payload.executionPrompt === "string" && payload.executionPrompt.trim()
? payload.executionPrompt.trim()
: typeof payload.body === "string"
? payload.body
: "";
const model =
typeof payload.model === "string" && payload.model.trim()
? payload.model.trim()
: config.defaultModel?.trim();
const args = [
...config.args,
"chat",
"-q",
executionPrompt,
"-Q",
"--source",
config.sourceTag,
];
if (model) {
args.push("-m", model);
}
const toolsets = config.toolsets ?? [];
const skills = config.skills ?? [];
if (toolsets.length > 0) {
args.push("-t", toolsets.join(","));
}
if (skills.length > 0) {
args.push("-s", skills.join(","));
}
return args;
}
export async function runHermesCommand(input: {
config: HermesBackendConfig;
payload: Record<string, unknown>;
}): Promise<ExecutionImmediateResult> {
const command = input.config.command;
if (!command) {
return createFailedResult("HERMES_COMMAND_NOT_CONFIGURED");
}
return new Promise((resolve) => {
const child = spawn(command, buildHermesArgs(input.config, input.payload), {
cwd: input.config.cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"] as const,
});
let stdout = "";
let stderr = "";
let settled = false;
const finish = (result: ExecutionImmediateResult) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
finish(createFailedResult("HERMES_TIMEOUT"));
}, input.config.timeoutMs);
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += String(chunk);
});
child.on("error", (error: Error) => {
finish(createFailedResult(error.message));
});
child.on("close", (code: number | null) => {
finish(
normalizeHermesProcessResult({
exitCode: code ?? 1,
stdout,
stderr,
}),
);
});
});
}
export const runHermesCommandForTesting = runHermesCommand;
export const createHermesProcessResultForTesting = normalizeHermesProcessResult;

View File

@@ -5,6 +5,60 @@ export type RelevantMemory = Pick<
"memoryId" | "scope" | "projectId" | "title" | "content" | "tags" | "memoryType" | "lastUsedAt" | "updatedAt" | "createdAt"
>;
function normalizeLexicalText(value: string) {
return value.trim().toLowerCase();
}
function tokenizeLexicalText(value: string) {
const normalized = normalizeLexicalText(value);
if (!normalized) {
return [];
}
return Array.from(
new Set(
normalized
.split(/[^\p{L}\p{N}]+/u)
.map((token) => token.trim())
.filter((token) => token.length >= 2),
),
);
}
function scoreProjectMemoryMatch(memory: RelevantMemory, requestText: string) {
const lowered = normalizeLexicalText(requestText);
if (!lowered) {
return 0;
}
const haystacks = [memory.projectId, memory.title, memory.content, ...(memory.tags ?? [])]
.filter((value): value is string => Boolean(value))
.map((value) => normalizeLexicalText(value));
let score = 0;
for (const value of haystacks) {
if (!value) {
continue;
}
if (lowered.includes(value) || value.includes(lowered)) {
score += 10;
}
}
const requestTokens = tokenizeLexicalText(requestText);
if (requestTokens.length === 0) {
return score;
}
const memoryTokens = new Set(haystacks.flatMap((value) => tokenizeLexicalText(value)));
for (const token of requestTokens) {
if (memoryTokens.has(token)) {
score += 3;
}
}
return score;
}
export function resolveRelevantMemories(input: {
projectId: string;
requestText?: string;
@@ -26,12 +80,13 @@ export function resolveRelevantMemories(input: {
: !lowered
? projectScoped.slice(0, 6)
: projectScoped
.filter((memory) => {
const haystacks = [memory.projectId, memory.title, memory.content, ...(memory.tags ?? [])]
.filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase());
return haystacks.some((value) => lowered.includes(value) || value.includes(lowered));
})
.map((memory) => ({
memory,
score: scoreProjectMemoryMatch(memory, lowered),
}))
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score)
.map((entry) => entry.memory)
.slice(0, 6);
const userMemories = input.memories.filter((memory) => memory.scope === "global").slice(0, 8);

View File

@@ -7,6 +7,10 @@ export interface RemoteExecutionResultInput {
replyBody?: string;
errorMessage?: string;
requestId?: string;
warnings?: Array<{
title?: string;
summary?: string;
}>;
}
export interface NormalizedRemoteExecutionResult {
@@ -18,6 +22,10 @@ export interface NormalizedRemoteExecutionResult {
replyBody?: string;
errorMessage?: string;
requestId?: string;
warnings?: Array<{
title: string;
summary: string;
}>;
}
function trimToDefined(value: string | undefined) {
@@ -56,12 +64,28 @@ function buildThreadEnvironmentErrorMessage() {
return "THREAD_ENVIRONMENT_INVALID: 线程返回了内部环境提示,已拦截,请检查线程绑定或工作目录。";
}
function normalizeExecutionWarnings(
warnings: RemoteExecutionResultInput["warnings"],
): NormalizedRemoteExecutionResult["warnings"] {
const normalized = (warnings ?? [])
.map((warning) => ({
title: trimToDefined(warning?.title),
summary: trimToDefined(warning?.summary),
}))
.filter(
(warning): warning is { title: string; summary: string } =>
Boolean(warning.title && warning.summary),
);
return normalized.length > 0 ? normalized : undefined;
}
export function normalizeRemoteExecutionResult(
input: RemoteExecutionResultInput,
): NormalizedRemoteExecutionResult {
const rawThreadReply = trimToDefined(input.rawThreadReply);
const replyBody = trimToDefined(input.replyBody);
const errorMessage = trimToDefined(input.errorMessage);
const warnings = normalizeExecutionWarnings(input.warnings);
const hasEnvironmentDiagnostic =
looksLikeThreadEnvironmentDiagnostic(rawThreadReply) ||
looksLikeThreadEnvironmentDiagnostic(replyBody);
@@ -74,6 +98,7 @@ export function normalizeRemoteExecutionResult(
targetThreadId: trimToDefined(input.targetThreadId),
errorMessage: errorMessage || buildThreadEnvironmentErrorMessage(),
requestId: trimToDefined(input.requestId),
warnings,
};
}
@@ -86,6 +111,7 @@ export function normalizeRemoteExecutionResult(
replyBody,
errorMessage,
requestId: trimToDefined(input.requestId),
warnings,
};
}

View File

@@ -33,6 +33,7 @@ export interface ExecutionImmediateCompletedResult {
status: "completed";
backendId: string;
output: string;
sessionId?: string;
}
export interface ExecutionImmediateFailedResult {

View File

@@ -39,6 +39,81 @@ test("ProjectDetailActivity keeps a rendered project snapshot for append-only re
/private boolean trySkipUnchangedRealtimeMessagesPatch\(JSONObject projectMessagesPayload\)/,
"expected chat page to expose a duplicate-payload fast path",
);
assert.match(
source,
/private boolean hasMatchingExecutionWarnings\(JSONObject currentPayload,\s*JSONObject nextPayload\)/,
"expected chat page to compare executionWarnings separately from the message list",
);
assert.match(
source,
/private boolean hasMatchingConversationTasks\(JSONObject currentPayload,\s*JSONObject nextPayload\)/,
"expected chat page to compare conversationTasks separately from the message list",
);
assert.match(
source,
/if \(!hasMatchingExecutionWarnings\(currentRenderedProjectPayload,\s*projectMessagesPayload\)\) \{\s*return false;\s*\}/,
"expected append-only realtime patches to fall back when warning payloads changed",
);
assert.match(
source,
/if \(!hasMatchingConversationTasks\(currentRenderedProjectPayload,\s*projectMessagesPayload\)\) \{\s*return false;\s*\}/,
"expected append-only realtime patches to fall back when task payloads changed",
);
assert.match(
source,
/JSONObject conversationTask = findConversationTask\(currentRenderedProjectPayload,\s*messageId\);/,
"expected each message view to look up a task summary by request message id",
);
assert.match(
source,
/if \(tryPatchRealtimeExecutionWarnings\(projectMessagesPayload\)\) \{\s*return true;\s*\}/,
"expected chat page to patch warning-only realtime changes before falling back to full rerender",
);
assert.match(
source,
/private boolean tryPatchRealtimeExecutionWarnings\(JSONObject projectMessagesPayload\)/,
"expected chat page to expose a focused warning patch helper",
);
assert.match(
source,
/replaceMessageViewById\(messageId,\s*buildMessageView\(message\)\);/,
"expected warning-only patches to rerender only the affected message view",
);
assert.match(
source,
/private void replaceMessageViewById\(String messageId,\s*View nextMessageView\)/,
"expected chat page to expose a helper for targeted message view replacement",
);
assert.match(
source,
/wrapper\.addView\(statusRow\);/,
"expected each message bubble to append a compact status row",
);
assert.match(
source,
/List<JSONObject> messageWarnings = buildMessageWarnings\(currentRenderedProjectPayload,\s*messageId\);/,
"expected message views to gather grouped warnings for the status row",
);
assert.match(
source,
/String currentFingerprint = buildStatusFingerprint\(messageId, currentRenderedProjectPayload\);/,
"expected realtime patches to compute the current status fingerprint before replacing a message view",
);
assert.match(
source,
/String nextFingerprint = buildStatusFingerprint\(messageId, projectMessagesPayload\);/,
"expected realtime patches to compute a fingerprint before replacing a message view",
);
assert.match(
source,
/if \(!TextUtils\.equals\(currentFingerprint,\s*nextFingerprint\)\) \{/,
"expected realtime warning patches to branch on status fingerprint changes before replacing views",
);
assert.match(
source,
/if \(hasMatchingExecutionWarnings\(currentRenderedProjectPayload,\s*projectMessagesPayload\)\s*&&\s*hasMatchingConversationTasks\(currentRenderedProjectPayload,\s*projectMessagesPayload\)\) \{\s*return false;\s*\}/,
"expected status-only patch path to stay idle only when both warnings and task payloads are unchanged",
);
});
test("ProjectDetailActivity suppresses intermediate layouts while rebuilding or appending chat content", async () => {

View File

@@ -34,4 +34,75 @@ test("ProjectDetailActivity applies lightweight realtime chat payloads before sc
/renderLoadedProjectSnapshot\(new ProjectSnapshot\(projectMessagesPayload,\s*null,\s*null\)\);/,
"expected chat page to render the local realtime payload without forcing a network request",
);
assert.match(
source,
/JSONArray executionWarnings = projectMessagesPayload\.optJSONArray\("executionWarnings"\);/,
"expected chat page to read executionWarnings from the lightweight realtime payload",
);
assert.match(
source,
/LinearLayout statusRow = BossUi\.buildMessageStatusRow\(this, message, conversationTask, messageWarnings, outgoing\);/,
"expected each rendered message to create a compact status row",
);
assert.match(
source,
/private List<JSONObject> buildMessageWarnings\(JSONObject payload, String messageId\)/,
"expected a helper returning grouped warnings per message",
);
assert.match(
source,
/if \(!TextUtils\.equals\(currentFingerprint,\s*nextFingerprint\)\) \{/,
"expected realtime warning patches to branch on fingerprint differences before replacing views",
);
assert.match(
source,
/replaceMessageViewById\(messageId,\s*buildMessageView\(message\)\);/,
"expected realtime warning patches to replace only the affected message after fingerprint differences",
);
const warningPatchMethod = source.match(
/private boolean tryPatchRealtimeExecutionWarnings\(JSONObject projectMessagesPayload\) \{[\s\S]*?\n \}/,
);
assert.ok(warningPatchMethod, "expected to locate the warning patch helper body");
const snapshotSwapCount =
warningPatchMethod[0].match(/currentRenderedProjectPayload = nextPayloadCopy;/g)?.length ?? 0;
assert.equal(
snapshotSwapCount,
1,
"expected warning patch helper to swap the rendered payload only once after all message diffs are processed",
);
});
test("BossUi keeps a detail-only message status row visible", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/BossUi.java");
assert.match(
source,
/boolean hasDetail = !TextUtils\.isEmpty\(detailText\);/,
"expected message status rows to detect detail-only status text",
);
assert.match(
source,
/if \(!hasTask && !hasWarnings && !hasDetail\) \{\s*row\.setVisibility\(View\.GONE\);\s*return row;\s*\}/,
"expected message status rows to stay visible whenever detail text exists",
);
assert.match(
source,
/if \(hasDetail\) \{\s*TextView detailView = new TextView\(context\);/,
"expected detail-only rows to still render their muted status text",
);
});
test("ProjectDetailActivity bypasses realtime message-only patching when group dispatch or repair state is active", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(
source,
/if \(shouldBypassRealtimeMessagesPatchForGroupState\(\)\) \{\s*return false;\s*\}/,
"expected realtime message patching to fall back to a full reload when group dispatch or repair state could be stale",
);
assert.match(
source,
/private boolean shouldBypassRealtimeMessagesPatchForGroupState\(\) \{/,
"expected a dedicated helper guarding the fast patch path for group-only state",
);
});

View File

@@ -0,0 +1,122 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
async function readSource(path: string) {
return readFile(new URL(path, import.meta.url), "utf8");
}
test("ProjectDetailActivity reads group dispatch and participant state from project detail payload", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(
source,
/detailResponse\.json\.optJSONArray\("dispatchPlans"\)/,
"expected project chat detail refreshes to read dispatchPlans directly from project detail payload",
);
assert.match(
source,
/detailResponse\.json\.optJSONObject\("participantsPayload"\)/,
"expected project chat detail refreshes to read participantsPayload directly from project detail payload",
);
assert.doesNotMatch(
source,
/apiClient\.getDispatchPlans\(projectId\)/,
"expected project chat detail refreshes to stop issuing a separate dispatch plans request",
);
assert.doesNotMatch(
source,
/apiClient\.getConversationParticipants\(projectId\)/,
"expected project chat detail refreshes to stop issuing a separate participants request",
);
});
test("GroupInfoActivity derives participants state from project detail payload", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java");
assert.match(
source,
/JSONObject participantsPayload = extractParticipantsPayload\(detailResponse\.json\);/,
"expected group info page to derive participants from the detail payload before rendering",
);
assert.doesNotMatch(
source,
/apiClient\.getConversationParticipants\(projectId\)/,
"expected group info page to stop issuing a separate participants request",
);
assert.match(
source,
/groupRepairJustApplied = true;/,
"expected group info page to persist a successful repair flag across the immediate reload",
);
assert.match(
source,
/if \(groupRepairJustApplied\) \{/,
"expected group info page to render a durable success acknowledgement after repair reload",
);
assert.match(
source,
/群成员已更新/,
"expected group info page to show explicit success copy after a repair completes",
);
});
test("ConversationInfoActivity derives participants state from project detail payload", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ConversationInfoActivity.java");
assert.match(
source,
/JSONObject participantsPayload = extractParticipantsPayload\(detailResponse\.json\);/,
"expected conversation info page to derive participants from the detail payload before rendering",
);
assert.doesNotMatch(
source,
/apiClient\.getConversationParticipants\(projectId\)/,
"expected conversation info page to stop issuing a separate participants request",
);
});
test("GroupCreateActivity reuses project detail payload when launched from a source conversation", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java");
assert.match(
source,
/BossApiClient\.ApiResponse detailResponse = apiClient\.getProjectDetail\(sourceProjectId\);/,
"expected group creation from a source conversation to load source detail once",
);
assert.match(
source,
/JSONObject participantsPayload = extractParticipantsPayload\(detailResponse\.json,\s*sourceProjectId\);/,
"expected group creation page to derive source participants from project detail payload",
);
assert.doesNotMatch(
source,
/apiClient\.getConversationParticipants\(sourceProjectId\)/,
"expected group creation page to stop issuing a separate participants request for the source conversation",
);
});
test("BossApiClient no longer keeps unused group detail compatibility wrappers", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/BossApiClient.java");
assert.doesNotMatch(
source,
/public ApiResponse getDispatchPlans\(String projectId\)/,
"expected BossApiClient to drop the unused dispatch plans wrapper once all screens read from project detail",
);
assert.doesNotMatch(
source,
/public ApiResponse getConversationParticipants\(String projectId\)/,
"expected BossApiClient to drop the unused participants wrapper once all screens read from project detail",
);
});
test("ProjectDetailActivity uses a distinct repair meta copy when only one valid thread remains", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(
source,
/validParticipantCount > 0[\s\S]*当前仅有 "\s*\+\s*validParticipantCount\s*\+\s*" 个真实线程成员/,
"expected group repair card copy to distinguish one-valid-thread state from zero-valid-thread state",
);
});

View File

@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
async function readSource(path: string) {
return readFile(new URL(path, import.meta.url), "utf8");
}
test("ProjectChatUiState tracks dispatch execution ids for reply wait after confirming a group dispatch", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java");
assert.match(
source,
/public final List<String> executionIds;/,
"expected ReplyWaitSpec to retain the dispatch execution ids it is waiting on",
);
assert.match(
source,
/JSONArray executions = response\.optJSONArray\("executions"\);/,
"expected dispatch confirm wait resolution to inspect the executions returned by the server",
);
assert.match(
source,
/collectExecutionIds\(executions\)/,
"expected dispatch confirm wait resolution to normalize execution ids into the wait spec",
);
assert.match(
source,
/hasTrackedDispatchExecutionReply\(\s*@Nullable JSONArray dispatchPlans,\s*@Nullable List<String> executionIds\s*\)/,
"expected a helper that checks reply progress against tracked dispatch executions",
);
});
test("ProjectDetailActivity polls dispatch reply waits against tracked execution ids instead of only latest message id", async () => {
const source = await readSource("../android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java");
assert.match(
source,
/enqueueReplyWaitPoll\(waitSpec,\s*includeDispatchPlans\);/,
"expected reply wait polling to receive the full wait spec, not just a baseline message id",
);
assert.match(
source,
/private void pollUntilReply\(\s*ProjectChatUiState\.ReplyWaitSpec waitSpec,\s*boolean includeDispatchPlans\s*\)/,
"expected pollUntilReply to read the richer wait spec",
);
assert.match(
source,
/ProjectChatUiState\.hasTrackedDispatchExecutionReply\(snapshot\.dispatchPlans,\s*waitSpec\.executionIds\)/,
"expected reply polling to use tracked dispatch executions when waiting on group replies",
);
});

View File

@@ -15,6 +15,8 @@ let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/task
let applyImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let AUTH_SESSION_COOKIE = "";
async function setup() {
@@ -46,6 +48,8 @@ async function setup() {
applyImportDraftRoute = applyModule.POST;
createAuthSession = data.createAuthSession;
readState = data.readState;
saveAiAccount = data.saveAiAccount;
updateProjectAgentControls = data.updateProjectAgentControls;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
@@ -85,6 +89,25 @@ async function createAuthedRequestFor(
test("device import draft review queues only the resolution task, then completion writes back a ready resolution and apply still works", async () => {
await setup();
await saveAiAccount({
accountId: "master-codex-device-import-policy",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "local-codex-node",
nodeLabel: "本机 Codex",
model: "gpt-5.4-mini",
enabled: true,
setActive: true,
loginStatusNote: "用于设备导入深度任务模型策略测试。",
});
await updateProjectAgentControls("master-agent", {
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
});
const enrollmentResponse = await createEnrollmentRoute(
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
@@ -200,6 +223,8 @@ test("device import draft review queues only the resolution task, then completio
task.status === "queued",
);
assert.ok(resolutionTask, "expected import review to leave a queued master-agent task trace");
assert.equal(resolutionTask?.executionModel, "gpt-5.4");
assert.equal(resolutionTask?.executionReasoningEffort, "high");
const understandingTask = reviewedState.masterAgentTasks.find(
(task) =>
task.taskType === "conversation_reply" &&

View File

@@ -7,6 +7,7 @@ import { NextRequest } from "next/server";
let runtimeRoot = "";
let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
let getProjectRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
let getDispatchPlansRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route"))["GET"];
let confirmDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route"))["POST"];
let rejectDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route"))["POST"];
@@ -29,8 +30,9 @@ async function setup() {
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [messageModule, plansModule, confirmModule, rejectModule, retryModule, reminderModule, data, auth] = await Promise.all([
const [messageModule, projectModule, plansModule, confirmModule, rejectModule, retryModule, reminderModule, data, auth] = await Promise.all([
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
import("../src/app/api/v1/projects/[projectId]/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts"),
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts"),
@@ -41,6 +43,7 @@ async function setup() {
]);
postMessageRoute = messageModule.POST;
getProjectRoute = projectModule.GET;
getDispatchPlansRoute = plansModule.GET;
confirmDispatchPlanRoute = confirmModule.POST;
rejectDispatchPlanRoute = rejectModule.POST;
@@ -334,6 +337,234 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms
assert.equal(executionTask?.orchestrationBackendLabel, "Boss Native Orchestrator");
});
test("GET /api/v1/projects/[projectId]/dispatch-plans includes execution summaries after confirmation", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
const confirmResponse = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{ approvedTargetProjectIds: [approvedTargetProjectId] },
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(confirmResponse.status, 200);
const response = await getDispatchPlansRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans`,
"GET",
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
plans: Array<{
planId: string;
executions?: Array<{
executionId: string;
targetProjectId: string;
targetThreadId: string;
status: string;
resultMessageId?: string;
}>;
}>;
};
assert.equal(payload.ok, true);
assert.equal(payload.plans[0]?.planId, dispatchPlan.planId);
assert.ok(payload.plans[0]?.executions?.[0], "expected confirmed plan to expose its execution summaries");
assert.equal(payload.plans[0]?.executions?.[0]?.targetProjectId, approvedTargetProjectId);
assert.equal(payload.plans[0]?.executions?.[0]?.status, "queued");
});
test("GET /api/v1/projects/[projectId] includes group dispatch and participant state for the chat surface", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const response = await getProjectRoute(
await createAuthedRequest(`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}`, "GET"),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
dispatchPlans?: Array<{
planId: string;
status?: string;
summary?: string;
targets?: Array<{ projectId: string; threadDisplayName: string }>;
executions?: Array<{ executionId: string; status: string }>;
}>;
participantsPayload?: {
projectId: string;
participants: Array<{ projectId: string; status?: string; canOpenProject?: boolean }>;
repairRequired: boolean;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.dispatchPlans?.[0]?.planId, dispatchPlan.planId);
assert.equal(payload.dispatchPlans?.[0]?.status, "pending_user_confirmation");
assert.ok(payload.dispatchPlans?.[0]?.summary, "expected project detail to include dispatch summary");
assert.ok(payload.dispatchPlans?.[0]?.targets?.length, "expected project detail to include dispatch targets");
assert.equal(payload.dispatchPlans?.[0]?.targets?.[0]?.projectId, dispatchPlan.targets[0]?.projectId);
assert.ok(payload.participantsPayload, "expected project detail to include participantsPayload");
assert.equal(payload.participantsPayload?.projectId, groupProject.id);
assert.equal(payload.participantsPayload?.repairRequired, false);
assert.ok((payload.participantsPayload?.participants.length ?? 0) >= 2);
assert.equal(payload.participantsPayload?.participants[0]?.status, "active");
assert.equal(payload.participantsPayload?.participants[0]?.canOpenProject, true);
});
test("GET /api/v1/projects/[projectId] includes dispatch execution summaries after confirmation", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
assert.ok(approvedTargetProjectId, "expected a recommended target project");
const confirmResponse = await confirmDispatchPlanRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
"POST",
{ approvedTargetProjectIds: [approvedTargetProjectId] },
),
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
);
assert.equal(confirmResponse.status, 200);
const response = await getProjectRoute(
await createAuthedRequest(`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}`, "GET"),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
dispatchPlans?: Array<{
planId: string;
executions?: Array<{
executionId: string;
targetProjectId: string;
targetThreadId: string;
status: string;
}>;
}>;
};
assert.equal(payload.ok, true);
assert.equal(payload.dispatchPlans?.[0]?.planId, dispatchPlan.planId);
assert.ok(payload.dispatchPlans?.[0]?.executions?.[0], "expected project detail to include confirmed execution summaries");
assert.equal(payload.dispatchPlans?.[0]?.executions?.[0]?.targetProjectId, approvedTargetProjectId);
assert.equal(payload.dispatchPlans?.[0]?.executions?.[0]?.status, "queued");
});
test("GET /api/v1/projects/[projectId] marks invalid group members as repair-required in detail payload", async () => {
const singles = await ensureTwoSingleThreadProjects();
const groupProject = await createProjectGroupChat({
sourceProjectId: singles[0].id,
memberProjectIds: [singles[1].id],
createdBy: "17600003315",
});
const state = await readState();
await writeState({
...state,
projects: state.projects.map((project) =>
project.id === groupProject.id
? {
...project,
groupMembers: [
{
projectId: "master-agent",
deviceId: "mac-studio",
threadId: "master-agent-thread",
threadDisplayName: "主 Agent 汇总",
folderName: "主控线程",
},
],
}
: project,
),
});
const response = await getProjectRoute(
await createAuthedRequest(`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}`, "GET"),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
participantsPayload?: {
repairRequired: boolean;
validParticipantCount: number;
invalidParticipantCount: number;
participants: Array<{ projectId: string; status?: string; canOpenProject?: boolean }>;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.participantsPayload?.repairRequired, true);
assert.equal(payload.participantsPayload?.validParticipantCount, 0);
assert.equal(payload.participantsPayload?.invalidParticipantCount, 1);
assert.equal(payload.participantsPayload?.participants[0]?.projectId, "master-agent");
assert.equal(payload.participantsPayload?.participants[0]?.status, "invalid_target");
assert.equal(payload.participantsPayload?.participants[0]?.canOpenProject, true);
});
test("GET /api/v1/projects/[projectId] marks missing group members as repair-required in detail payload", async () => {
const singles = await ensureTwoSingleThreadProjects();
const groupProject = await createProjectGroupChat({
sourceProjectId: singles[0].id,
memberProjectIds: [singles[1].id],
createdBy: "17600003315",
});
const state = await readState();
await writeState({
...state,
projects: state.projects.map((project) =>
project.id === groupProject.id
? {
...project,
groupMembers: [
{
projectId: "missing-project-1",
deviceId: "mac-studio",
threadId: "missing-thread-1",
threadDisplayName: "丢失线程引用",
folderName: "异常引用",
},
],
}
: project,
),
});
const response = await getProjectRoute(
await createAuthedRequest(`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}`, "GET"),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
participantsPayload?: {
repairRequired: boolean;
validParticipantCount: number;
invalidParticipantCount: number;
participants: Array<{ projectId: string; status?: string; canOpenProject?: boolean }>;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.participantsPayload?.repairRequired, true);
assert.equal(payload.participantsPayload?.validParticipantCount, 0);
assert.equal(payload.participantsPayload?.invalidParticipantCount, 1);
assert.equal(payload.participantsPayload?.participants[0]?.projectId, "missing-project-1");
assert.equal(payload.participantsPayload?.participants[0]?.status, "missing_project");
assert.equal(payload.participantsPayload?.participants[0]?.canOpenProject, false);
});
test("confirming a dispatch plan with rememberLightReminder persists the group reminder preference", async () => {
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;

View File

@@ -90,6 +90,19 @@ test("listExecutionBackendChoices keeps claw disabled by default", () => {
);
});
test("listExecutionBackendChoices keeps hermes disabled by default", () => {
const backends = listExecutionBackendChoices({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
});
assert.deepEqual(
backends.map((backend) => backend.backendId),
["master-codex-node", "openai-api"],
);
});
test("selectExecutionBackendForTesting honors an explicit claw request when claw is enabled", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
@@ -135,3 +148,49 @@ test("selectExecutionBackendForTesting falls back when claw is requested but una
assert.equal(backend.backendId, "master-codex-node");
});
test("selectExecutionBackendForTesting honors an explicit hermes request when hermes is enabled", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
requestedBackendId: "hermes-runtime",
hermes: {
enabled: true,
selectable: true,
availability: {
status: "ready",
selectable: true,
configured: true,
reason: "ready",
reasonLabel: "Hermes Runtime 可用。",
},
supportsKinds: ["master_agent_reply", "thread_reply"],
},
});
assert.equal(backend.backendId, "hermes-runtime");
});
test("selectExecutionBackendForTesting falls back when hermes is requested but unavailable", async () => {
const backend = await selectExecutionBackendForTesting({
primary: { provider: "master_codex_node", status: "ready" },
backups: [{ provider: "openai_api", status: "ready" }],
requestKind: "master_agent_reply",
requestedBackendId: "hermes-runtime",
hermes: {
enabled: false,
selectable: false,
availability: {
status: "disabled",
selectable: false,
configured: false,
reason: "disabled",
reasonLabel: "Hermes Runtime 当前未启用。",
},
supportsKinds: ["master_agent_reply"],
},
});
assert.equal(backend.backendId, "master-codex-node");
});

View File

@@ -49,6 +49,7 @@ test("ExecutionResult 类型守卫能区分 queued 与 immediate", () => {
status: "completed",
backendId: "openai-api",
output: "done",
sessionId: "session-completed-1",
};
const failed: ExecutionImmediateResult = {
status: "failed",

View File

@@ -39,6 +39,40 @@ test("MemoryResolver 在 master-agent 会话下优先挑当前请求命中的项
assert.equal(resolved.projectMemories[0]?.projectId, "boss-console");
});
test("MemoryResolver 会按请求里的自然语言关键词命中更相关的项目记忆", () => {
const resolved = resolveRelevantMemoriesForTesting({
projectId: "master-agent",
requestText: "继续推进 boss 项目的会话归档逻辑",
memories: [
{
memoryId: "m1",
scope: "project",
projectId: "boss-console",
title: "boss 项目进度",
content: "boss 项目当前按项目聚合加线程下钻展示。",
tags: ["boss", "会话"],
memoryType: "project_progress",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
memoryId: "m2",
scope: "project",
projectId: "project-wenshenapp",
title: "wenshenapp 项目进度",
content: "wenshenapp 当前只有一个主线程。",
tags: ["wenshenapp"],
memoryType: "project_progress",
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
],
});
assert.equal(resolved.projectMemories.length, 1);
assert.equal(resolved.projectMemories[0]?.projectId, "boss-console");
});
test("MemoryResolver 会保留全局记忆的输入顺序并只截断到 8 条", () => {
const resolved = resolveRelevantMemoriesForTesting({
projectId: "master-agent",
@@ -115,6 +149,40 @@ test("Runtime MemoryResolver 会优先排布 workflow_rule 和 user_preference
);
});
test("Runtime MemoryResolver 也会按自然语言关键词优先挑中更相关的项目记忆", () => {
const resolved = resolveRuntimeRelevantMemoriesForTesting({
projectId: "master-agent",
requestText: "继续推进 boss 项目的会话归档逻辑",
memories: [
{
memoryId: "m2",
scope: "project",
projectId: "project-wenshenapp",
title: "wenshenapp 项目进度",
content: "wenshenapp 当前只有一个主线程。",
tags: ["wenshenapp"],
memoryType: "project_progress",
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
{
memoryId: "m1",
scope: "project",
projectId: "boss-console",
title: "boss 项目进度",
content: "boss 项目当前按项目聚合加线程下钻展示。",
tags: ["boss", "会话"],
memoryType: "project_progress",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
});
assert.equal(resolved.projectMemories.length, 1);
assert.equal(resolved.projectMemories[0]?.projectId, "boss-console");
});
test("Runtime MemoryResolver 在 master-agent 非空请求但无 lexical 命中时回退到前 6 个项目记忆", () => {
const resolved = resolveRuntimeRelevantMemoriesForTesting({
projectId: "master-agent",

View File

@@ -9,6 +9,8 @@ let runtimeRoot = "";
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let createIndependentGroupChat: (typeof import("../src/lib/boss-data"))["createIndependentGroupChat"];
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 writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let AUTH_SESSION_COOKIE: string;
@@ -32,6 +34,8 @@ async function setup() {
POST = routePost;
createAuthSession = data.createAuthSession;
createIndependentGroupChat = data.createIndependentGroupChat;
saveAiAccount = data.saveAiAccount;
updateProjectAgentControls = data.updateProjectAgentControls;
readState = data.readState;
writeState = data.writeState;
baseState = structuredClone(await readState());
@@ -151,6 +155,25 @@ async function ensureTwoSingleThreadProjects() {
test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for group text messages", async () => {
await setup();
await saveAiAccount({
accountId: "master-codex-smart-policy",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "local-codex-node",
nodeLabel: "本机 Codex",
model: "gpt-5.4-mini",
enabled: true,
setActive: true,
loginStatusNote: "用于深度任务模型策略测试。",
});
await updateProjectAgentControls("master-agent", {
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
});
const memberProjects = await ensureTwoSingleThreadProjects();
assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects");
@@ -199,6 +222,8 @@ test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for gro
1,
"expected group messages to enqueue a master-agent dispatch recommendation task",
);
assert.equal(queuedGroupDispatchTasks[0]?.executionModel, "gpt-5.4");
assert.equal(queuedGroupDispatchTasks[0]?.executionReasoningEffort, "high");
});
test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for single-thread projects", async () => {

View File

@@ -67,27 +67,49 @@ async function ensureTwoSingleThreadProjects() {
return singles;
}
assert.ok(singles[0], "expected seeded single-thread project");
const seed = singles[0];
const clone = {
...seed,
id: "repair-thread-clone",
name: "Repair Thread Clone",
const generatedProjects = Array.from({ length: 2 - singles.length }, (_, index) => ({
id: `repair-thread-${index + 1}`,
name: `Repair Thread ${index + 1}`,
pinned: false,
systemPinned: false,
deviceIds: ["mac-studio"],
preview: "用于群成员修复 contract 的测试线程。",
updatedAt: "2026-03-30T10:00:00+08:00",
lastMessageAt: "2026-03-30T10:00:00+08:00",
isGroup: false,
threadMeta: {
...seed.threadMeta,
projectId: "repair-thread-clone",
threadId: "repair-thread-clone",
threadDisplayName: "维修回归线程",
projectId: `repair-thread-${index + 1}`,
threadId: `repair-thread-${index + 1}`,
threadDisplayName: `维修回归线程 ${index + 1}`,
folderName: "repair-folder",
codexThreadRef: "repair-thread-clone",
activityIconCount: 0,
updatedAt: "2026-03-30T10:00:00+08:00",
codexThreadRef: `repair-thread-${index + 1}`,
codexFolderRef: "repair-folder",
},
};
groupMembers: [],
createdByAgent: true,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [
{
id: `msg-repair-thread-${index + 1}`,
sender: "device" as const,
senderLabel: "Win GPU / Codex",
body: "用于群成员修复 contract 的测试线程。",
sentAt: "2026-03-30T10:00:00+08:00",
kind: "text" as const,
},
],
goals: [],
versions: [],
}));
await writeState({
...state,
projects: [...state.projects, clone],
projects: [...state.projects, ...generatedProjects],
});
const nextState = await readState();
return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup);
@@ -215,3 +237,28 @@ test("POST /api/v1/projects/[projectId]/participants replaces dirty members with
);
assert.ok(repairNotice, "expected a group repair system notice");
});
test("POST /api/v1/projects/[projectId]/participants maps stale member errors to readable copy", async () => {
await setup();
const singles = await ensureTwoSingleThreadProjects();
const groupProject = await createProjectGroupChat({
sourceProjectId: singles[0].id,
memberProjectIds: [singles[1].id],
createdBy: "17600003315",
});
const response = await updateParticipantsRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/participants`,
"POST",
{ memberProjectIds: [singles[0].id, "missing-thread-project"] },
),
{ params: Promise.resolve({ projectId: groupProject.id }) },
);
assert.equal(response.status, 400);
const payload = (await response.json()) as { ok: boolean; message: string };
assert.equal(payload.ok, false);
assert.equal(payload.message, "有线程已经不存在,请刷新后重新选择。");
assert.notEqual(payload.message, "GROUP_CHAT_MEMBER_NOT_FOUND");
});

View File

@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import test from "node:test";
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import {
getHermesBackendAvailabilityForTesting,
getHermesBackendConfigForTesting,
isHermesBackendConfiguredForTesting,
} from "../src/lib/execution/backends/hermes-config.ts";
function snapshotEnv() {
return {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_WORKDIR: process.env.BOSS_HERMES_WORKDIR,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
BOSS_HERMES_DEFAULT_MODEL: process.env.BOSS_HERMES_DEFAULT_MODEL,
BOSS_HERMES_TOOLSETS: process.env.BOSS_HERMES_TOOLSETS,
BOSS_HERMES_SKILLS: process.env.BOSS_HERMES_SKILLS,
};
}
function restoreEnv(snapshot: ReturnType<typeof snapshotEnv>) {
process.env.BOSS_HERMES_ENABLED = snapshot.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = snapshot.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = snapshot.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_WORKDIR = snapshot.BOSS_HERMES_WORKDIR;
process.env.BOSS_HERMES_TIMEOUT_MS = snapshot.BOSS_HERMES_TIMEOUT_MS;
process.env.BOSS_HERMES_DEFAULT_MODEL = snapshot.BOSS_HERMES_DEFAULT_MODEL;
process.env.BOSS_HERMES_TOOLSETS = snapshot.BOSS_HERMES_TOOLSETS;
process.env.BOSS_HERMES_SKILLS = snapshot.BOSS_HERMES_SKILLS;
}
test("Hermes backend 在未配置时默认关闭", () => {
const previous = snapshotEnv();
delete process.env.BOSS_HERMES_ENABLED;
delete process.env.BOSS_HERMES_COMMAND;
delete process.env.BOSS_HERMES_ARGS;
delete process.env.BOSS_HERMES_WORKDIR;
delete process.env.BOSS_HERMES_TIMEOUT_MS;
delete process.env.BOSS_HERMES_DEFAULT_MODEL;
delete process.env.BOSS_HERMES_TOOLSETS;
delete process.env.BOSS_HERMES_SKILLS;
const config = getHermesBackendConfigForTesting();
assert.equal(config.enabled, false);
assert.equal(config.command, "hermes");
assert.equal(isHermesBackendConfiguredForTesting(config), false);
restoreEnv(previous);
});
test("Hermes backend 在配置完整时返回 command、args、toolsets 和 skills", () => {
const previous = snapshotEnv();
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = "hermes";
process.env.BOSS_HERMES_ARGS = "--profile prod";
process.env.BOSS_HERMES_WORKDIR = "/tmp/hermes";
process.env.BOSS_HERMES_TIMEOUT_MS = "39000";
process.env.BOSS_HERMES_DEFAULT_MODEL = "gpt-5.4";
process.env.BOSS_HERMES_TOOLSETS = "web,terminal";
process.env.BOSS_HERMES_SKILLS = "boss-dev,github";
const config = getHermesBackendConfigForTesting();
assert.equal(config.enabled, true);
assert.equal(config.command, "hermes");
assert.deepEqual(config.args, ["--profile", "prod"]);
assert.equal(config.cwd, "/tmp/hermes");
assert.equal(config.timeoutMs, 39000);
assert.equal(config.defaultModel, "gpt-5.4");
assert.deepEqual(config.toolsets, ["web", "terminal"]);
assert.deepEqual(config.skills, ["boss-dev", "github"]);
assert.equal(isHermesBackendConfiguredForTesting(config), true);
restoreEnv(previous);
});
test("Hermes backend availability 会在可执行命令和脚本都存在时返回 ready", async () => {
const previous = snapshotEnv();
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-config-"));
const scriptPath = path.join(tempDir, "hermes-smoke.mjs");
await writeFile(scriptPath, "console.log('ok');\n", "utf8");
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = scriptPath;
process.env.BOSS_HERMES_WORKDIR = tempDir;
try {
const availability = await getHermesBackendAvailabilityForTesting();
assert.equal(availability.status, "ready");
assert.equal(availability.selectable, true);
assert.equal(availability.reason, "ready");
} finally {
restoreEnv(previous);
await rm(tempDir, { recursive: true, force: true });
}
});
test("Hermes backend availability 会在脚本参数不存在时返回不可选", async () => {
const previous = snapshotEnv();
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-config-"));
const missingScript = path.join(tempDir, "missing-hermes-script.mjs");
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = missingScript;
process.env.BOSS_HERMES_WORKDIR = tempDir;
try {
const availability = await getHermesBackendAvailabilityForTesting();
assert.equal(availability.status, "misconfigured");
assert.equal(availability.selectable, false);
assert.equal(availability.reason, "script_not_found");
} finally {
restoreEnv(previous);
await rm(tempDir, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createHermesBackendForTesting } from "../src/lib/execution/backends/hermes-backend.ts";
test("Hermes backend 只在启用且请求类型受支持时 canHandle", async () => {
const backend = createHermesBackendForTesting({
config: {
enabled: true,
command: "hermes",
args: [],
timeoutMs: 45_000,
sourceTag: "tool",
},
runner: async () => ({
status: "completed",
backendId: "hermes-runtime",
output: "ok",
}),
});
assert.equal(
await backend.canHandle({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续",
}),
true,
);
assert.equal(
await backend.canHandle({
kind: "dispatch_execution",
projectId: "project-1",
requestMessageId: "msg-2",
body: "继续",
}),
false,
);
});
test("Hermes backend 执行时会把 executionPrompt、模型、toolsets 和 skills 交给 runner", async () => {
const calls: unknown[] = [];
const backend = createHermesBackendForTesting({
config: {
enabled: true,
command: "hermes",
args: ["--profile", "prod"],
timeoutMs: 45_000,
defaultModel: "gpt-5.4",
toolsets: ["web", "terminal"],
skills: ["boss-dev"],
sourceTag: "tool",
},
runner: async (input) => {
calls.push(input);
return {
status: "completed",
backendId: "hermes-runtime",
output: "链路正常",
};
},
});
const result = await backend.execute({
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续推进",
executionPrompt: "系统提示词 + 用户提示词 + 当前消息",
modelOverride: "gpt-5.5",
reasoningEffortOverride: "high",
});
assert.equal(result.status, "completed");
assert.deepEqual(calls, [
{
config: {
enabled: true,
command: "hermes",
args: ["--profile", "prod"],
timeoutMs: 45_000,
defaultModel: "gpt-5.4",
toolsets: ["web", "terminal"],
skills: ["boss-dev"],
sourceTag: "tool",
},
payload: {
kind: "master_agent_reply",
projectId: "master-agent",
requestMessageId: "msg-1",
body: "继续推进",
executionPrompt: "系统提示词 + 用户提示词 + 当前消息",
model: "gpt-5.5",
reasoningEffort: "high",
toolsets: ["web", "terminal"],
skills: ["boss-dev"],
},
},
]);
});
test("Hermes backend describe 返回稳定描述", async () => {
const backend = createHermesBackendForTesting({
config: {
enabled: true,
command: "hermes",
args: [],
timeoutMs: 45_000,
sourceTag: "tool",
},
runner: async () => ({
status: "completed",
backendId: "hermes-runtime",
output: "ok",
}),
});
const description = await backend.describe({
kind: "thread_reply",
projectId: "project-1",
requestMessageId: "msg-1",
body: "继续",
});
assert.deepEqual(description, {
backendId: "hermes-runtime",
label: "Hermes Runtime",
mode: "local",
});
});

157
tests/hermes-runner.test.ts Normal file
View File

@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { mkdtemp, realpath, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { runHermesCommandForTesting } from "../src/lib/execution/backends/hermes-runner.ts";
async function createTempScript(source: string) {
const dir = await mkdtemp(join(tmpdir(), "hermes-runner-"));
const scriptPath = join(dir, "hermes-script.mjs");
await writeFile(scriptPath, source, "utf8");
return { dir, scriptPath };
}
test("Hermes runner 会按固定 chat -q -Q 形态执行并提取正文", async () => {
const workspace = await mkdtemp(join(tmpdir(), "hermes-runner-cwd-"));
const expectedWorkspace = await realpath(workspace);
const { scriptPath } = await createTempScript(`
process.stdout.write(JSON.stringify({
argv: process.argv.slice(2),
cwd: process.cwd(),
envSource: process.env.HERMES_SESSION_SOURCE || ""
}) + "\\n");
process.stdout.write("Hermes smoke completed\\n\\n");
process.stdout.write("session_id: hermes-session-123\\n");
`);
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
cwd: workspace,
timeoutMs: 1000,
defaultModel: "gpt-5.4",
toolsets: ["web", "terminal"],
skills: ["boss-dev"],
sourceTag: "tool",
},
payload: {
executionPrompt: "请输出链路正常",
model: "gpt-5.5",
},
});
assert.equal(result.status, "completed");
if (result.status !== "completed") {
assert.fail("expected completed");
}
const lines = result.output.split("\n");
const metadata = JSON.parse(lines[0] ?? "{}") as {
argv: string[];
cwd: string;
envSource: string;
};
assert.deepEqual(metadata.argv, [
"chat",
"-q",
"请输出链路正常",
"-Q",
"--source",
"tool",
"-m",
"gpt-5.5",
"-t",
"web,terminal",
"-s",
"boss-dev",
]);
assert.equal(metadata.cwd, expectedWorkspace);
assert.equal(metadata.envSource, "");
assert.equal(lines.at(-1), "Hermes smoke completed");
assert.equal(result.sessionId, "hermes-session-123");
});
test("Hermes runner 会把非零退出码映射成 stderr 或退出码错误", async () => {
const { scriptPath } = await createTempScript(`
process.stderr.write("hermes crashed");
process.exit(2);
`);
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
sourceTag: "tool",
},
payload: {
executionPrompt: "anything",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /hermes crashed/);
});
test("Hermes runner 在输出只有 session_id 时会视为失败", async () => {
const { scriptPath } = await createTempScript(`
process.stdout.write("session_id: hermes-session-123\\n");
`);
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 1000,
sourceTag: "tool",
},
payload: {
executionPrompt: "anything",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /EMPTY_HERMES_RESPONSE/);
});
test("Hermes runner 超时后返回 HERMES_TIMEOUT", async () => {
const { scriptPath } = await createTempScript(`
setTimeout(() => {
process.stdout.write("late response\\n");
}, 500);
`);
const result = await runHermesCommandForTesting({
config: {
enabled: true,
command: process.execPath,
args: [scriptPath],
timeoutMs: 50,
sourceTag: "tool",
},
payload: {
executionPrompt: "slow",
},
});
assert.equal(result.status, "failed");
if (result.status !== "failed") {
assert.fail("expected failed");
}
assert.match(result.error, /HERMES_TIMEOUT/);
});

View File

@@ -169,6 +169,38 @@ test("master agent reply without target thread stays on ephemeral exec", () => {
]);
});
test("task execution model overrides local-agent default model", () => {
const execution = buildCodexTaskExecution(
{
masterAgentWorkdir: "/Users/kris/code/boss",
masterAgentSandbox: "workspace-write",
masterAgentModel: "gpt-5.4-mini",
},
{
taskType: "group_dispatch_plan",
executionPrompt: "请生成群聊分发方案",
executionModel: "gpt-5.4",
},
"/tmp/master.txt",
);
assert.equal(execution.mode, "ephemeral");
assert.deepEqual(execution.args, [
"exec",
"--ephemeral",
"--skip-git-repo-check",
"-C",
"/Users/kris/code/boss",
"-s",
"workspace-write",
"-o",
"/tmp/master.txt",
"-m",
"gpt-5.4",
"请生成群聊分发方案",
]);
});
test("conversation reply preflight fails closed when target cwd is missing", async () => {
const missingFolder = "/tmp/boss-local-agent-missing-workdir";
const stateDbPath = await createCodexStateDb([

View File

@@ -12,6 +12,7 @@ let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let getProjectAgentControls: (typeof import("../src/lib/boss-data"))["getProjectAgentControls"];
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let getProjectDetailView: (typeof import("../src/lib/boss-projections"))["getProjectDetailView"];
let getProjectRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
let getAgentControlsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/agent-controls/route"))["GET"];
@@ -39,6 +40,7 @@ async function setup() {
writeState = data.writeState;
updateProjectAgentControls = data.updateProjectAgentControls;
getProjectAgentControls = data.getProjectAgentControls;
saveAiAccount = data.saveAiAccount;
getProjectDetailView = projections.getProjectDetailView;
getProjectRoute = projectRouteModule.GET;
getAgentControlsRoute = agentControlsRouteModule.GET;
@@ -115,20 +117,32 @@ test("master-agent 会话可保存并读取模型与推理强度覆盖", async (
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-5.4",
reasoningEffortOverride: "high",
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
});
const controls = await getProjectAgentControls("master-agent");
assert.equal(controls?.modelOverride, "gpt-5.4");
assert.equal(controls?.reasoningEffortOverride, "high");
assert.equal(controls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(controls?.fastReasoningEffortOverride, "low");
assert.equal(controls?.smartModelOverride, "gpt-5.4");
assert.equal(controls?.smartReasoningEffortOverride, "high");
const state = await readState();
const project = state.projects.find((item) => item.id === "master-agent");
assert.equal(project?.agentControls?.modelOverride, "gpt-5.4");
assert.equal(project?.agentControls?.reasoningEffortOverride, "high");
assert.equal(project?.agentControls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(project?.agentControls?.fastReasoningEffortOverride, "low");
const detail = getProjectDetailView(state, "master-agent");
assert.equal(detail?.agentControls?.modelOverride, "gpt-5.4");
assert.equal(detail?.agentControls?.reasoningEffortOverride, "high");
assert.equal(detail?.agentControls?.smartModelOverride, "gpt-5.4");
assert.equal(detail?.agentControls?.smartReasoningEffortOverride, "high");
});
test("master-agent 对话控制路由可读写并回显到项目详情", async () => {
@@ -167,6 +181,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
body: JSON.stringify({
modelOverride: "gpt-5.4",
reasoningEffortOverride: "medium",
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
backendOverride: "claw-runtime",
}),
}),
@@ -179,6 +197,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: {
modelOverride?: string;
reasoningEffortOverride?: string;
fastModelOverride?: string;
fastReasoningEffortOverride?: string;
smartModelOverride?: string;
smartReasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
@@ -186,6 +208,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
assert.equal(postPayload.ok, true);
assert.equal(postPayload.controls?.modelOverride, "gpt-5.4");
assert.equal(postPayload.controls?.reasoningEffortOverride, "medium");
assert.equal(postPayload.controls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(postPayload.controls?.fastReasoningEffortOverride, "low");
assert.equal(postPayload.controls?.smartModelOverride, "gpt-5.4");
assert.equal(postPayload.controls?.smartReasoningEffortOverride, "high");
assert.equal(postPayload.controls?.backendOverride, "claw-runtime");
const getResponse = await getAgentControlsRoute(
@@ -202,6 +228,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: {
modelOverride?: string;
reasoningEffortOverride?: string;
fastModelOverride?: string;
fastReasoningEffortOverride?: string;
smartModelOverride?: string;
smartReasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
@@ -209,6 +239,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
assert.equal(getPayload.ok, true);
assert.equal(getPayload.controls?.modelOverride, "gpt-5.4");
assert.equal(getPayload.controls?.reasoningEffortOverride, "medium");
assert.equal(getPayload.controls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(getPayload.controls?.fastReasoningEffortOverride, "low");
assert.equal(getPayload.controls?.smartModelOverride, "gpt-5.4");
assert.equal(getPayload.controls?.smartReasoningEffortOverride, "high");
assert.equal(getPayload.controls?.backendOverride, "claw-runtime");
const projectResponse = await getProjectRoute(
@@ -225,6 +259,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
agentControls: {
modelOverride?: string;
reasoningEffortOverride?: string;
fastModelOverride?: string;
fastReasoningEffortOverride?: string;
smartModelOverride?: string;
smartReasoningEffortOverride?: string;
backendOverride?: string;
updatedAt: string;
} | null;
@@ -232,6 +270,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
assert.equal(projectPayload.ok, true);
assert.equal(projectPayload.agentControls?.modelOverride, "gpt-5.4");
assert.equal(projectPayload.agentControls?.reasoningEffortOverride, "medium");
assert.equal(projectPayload.agentControls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(projectPayload.agentControls?.fastReasoningEffortOverride, "low");
assert.equal(projectPayload.agentControls?.smartModelOverride, "gpt-5.4");
assert.equal(projectPayload.agentControls?.smartReasoningEffortOverride, "high");
assert.equal(projectPayload.agentControls?.backendOverride, "claw-runtime");
} finally {
if (previousEnv.BOSS_CLAW_ENABLED === undefined) delete process.env.BOSS_CLAW_ENABLED;
@@ -246,6 +288,154 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
}
});
test("master-agent 对话控制路由可读写 Hermes backendOverride", async () => {
await setup();
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-agent-controls-"));
const scriptPath = path.join(tempDir, "hermes-runtime.mjs");
await writeFile(scriptPath, "console.log('ok');\n", "utf8");
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_WORKDIR: process.env.BOSS_HERMES_WORKDIR,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = scriptPath;
process.env.BOSS_HERMES_WORKDIR = tempDir;
try {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
const headers = {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
};
const postResponse = await postAgentControlsRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", {
method: "POST",
headers,
body: JSON.stringify({
backendOverride: "hermes-runtime",
}),
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(postResponse.status, 200);
const postPayload = (await postResponse.json()) as {
ok: boolean;
controls: {
backendOverride?: string;
} | null;
hermesAvailability?: {
selectable?: boolean;
};
};
assert.equal(postPayload.ok, true);
assert.equal(postPayload.controls?.backendOverride, "hermes-runtime");
assert.equal(postPayload.hermesAvailability?.selectable, true);
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.backendOverride, "hermes-runtime");
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_WORKDIR = previousEnv.BOSS_HERMES_WORKDIR;
await rm(tempDir, { recursive: true, force: true });
}
});
test("普通线程对话控制路由可读写 Hermes backendOverride", async () => {
await setup();
const projectId = await ensureOrdinaryProject("ordinary-hermes-project");
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-thread-controls-"));
const scriptPath = path.join(tempDir, "hermes-runtime.mjs");
await writeFile(scriptPath, "console.log('ok');\n", "utf8");
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_WORKDIR: process.env.BOSS_HERMES_WORKDIR,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = scriptPath;
process.env.BOSS_HERMES_WORKDIR = tempDir;
try {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
const headers = {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
};
const postResponse = await postAgentControlsRoute(
new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/agent-controls`, {
method: "POST",
headers,
body: JSON.stringify({
backendOverride: "hermes-runtime",
}),
}),
{ params: Promise.resolve({ projectId }) },
);
assert.equal(postResponse.status, 200);
const postPayload = (await postResponse.json()) as {
ok: boolean;
controls: {
backendOverride?: string;
} | null;
hermesAvailability?: {
selectable?: boolean;
};
};
assert.equal(postPayload.ok, true);
assert.equal(postPayload.controls?.backendOverride, "hermes-runtime");
assert.equal(postPayload.hermesAvailability?.selectable, true);
const controls = await getProjectAgentControls(projectId, "17600003315");
assert.equal(controls?.backendOverride, "hermes-runtime");
const projectResponse = await getProjectRoute(
new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}`, {
method: "GET",
headers,
}),
{ params: Promise.resolve({ projectId }) },
);
assert.equal(projectResponse.status, 200);
const projectPayload = (await projectResponse.json()) as {
ok: boolean;
agentControls: {
backendOverride?: string;
} | null;
};
assert.equal(projectPayload.ok, true);
assert.equal(projectPayload.agentControls?.backendOverride, "hermes-runtime");
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_WORKDIR = previousEnv.BOSS_HERMES_WORKDIR;
await rm(tempDir, { recursive: true, force: true });
}
});
test("master-agent 对话控制按当前账号隔离,不会串到其他用户", async () => {
await setup();
@@ -357,6 +547,78 @@ test("master-agent 对话控制路由单字段更新不会清掉另一字段", a
assert.equal(payload.controls?.reasoningEffortOverride, "low");
});
test("master-agent 对话控制 GET 会返回当前可用模型与预设模型清单", async () => {
await setup();
await saveAiAccount({
accountId: "openai-model-catalog",
label: "OpenAI 主账号",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主账号",
model: "gpt-5.4-mini",
apiKey: "sk-openai-model-catalog",
enabled: true,
setActive: true,
loginStatusNote: "用于模型目录测试。",
});
await saveAiAccount({
accountId: "qwen-model-catalog",
label: "Qwen 备用",
role: "backup",
provider: "aliyun_qwen_api",
displayName: "Qwen 备用",
model: "qwen3.5-plus",
apiKey: "sk-qwen-model-catalog",
enabled: true,
setActive: false,
loginStatusNote: "用于模型目录测试。",
});
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
const response = await getAgentControlsRoute(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", {
method: "GET",
headers: {
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
modelCatalog?: {
availableModels?: string[];
selectableModels?: string[];
presetModels?: string[];
};
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.modelCatalog?.availableModels, ["gpt-5.4-mini", "qwen3.5-plus"]);
assert.ok(payload.modelCatalog?.selectableModels?.includes("gpt-5.4"));
assert.ok(payload.modelCatalog?.selectableModels?.includes("gpt-5.4-mini"));
assert.ok(payload.modelCatalog?.selectableModels?.includes("gpt-4.1"));
assert.ok(payload.modelCatalog?.selectableModels?.includes("gpt-4.1-mini"));
assert.ok(payload.modelCatalog?.selectableModels?.includes("qwen3.5-plus"));
assert.deepEqual(payload.modelCatalog?.presetModels, [
"gpt-5.4",
"gpt-5.4-mini",
"gpt-4.1",
"gpt-4.1-mini",
"qwen3.5-plus",
]);
});
test("全局接管默认会透传到普通线程会话详情", async () => {
await setup();
const projectId = await ensureOrdinaryProject("ordinary-takeover-project");

View File

@@ -87,6 +87,62 @@ test("当前对话 override 优先于主控账号默认值", async () => {
assert.equal(resolved.account.model, "gpt-4.1-mini");
});
test("主 Agent 模型策略会按聊天与深度任务选择不同默认模型", async () => {
await saveAiAccount({
accountId: "master-codex-primary",
label: "主 GPT",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主控",
model: "gpt-5.4",
apiKey: "sk-test-master-agent-policy",
enabled: true,
setActive: true,
loginStatusNote: "用于模型策略测试。",
});
await updateProjectAgentControls("master-agent", {
fastModelOverride: "gpt-5.4-mini",
fastReasoningEffortOverride: "low",
smartModelOverride: "gpt-5.4",
smartReasoningEffortOverride: "high",
});
const chatResolved = await resolveMasterAgentExecutionConfig(
"master-agent",
"17600003315",
"帮我看一下当前状态",
"chat",
);
assert.equal(chatResolved.model, "gpt-5.4-mini");
assert.equal(chatResolved.reasoningEffort, "low");
assert.equal(chatResolved.modelPolicy.mode, "fast");
const deepResolved = await resolveMasterAgentExecutionConfig(
"master-agent",
"17600003315",
"深度理解当前项目进度",
"deep_task",
);
assert.equal(deepResolved.model, "gpt-5.4");
assert.equal(deepResolved.reasoningEffort, "high");
assert.equal(deepResolved.modelPolicy.mode, "smart");
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-4.1",
reasoningEffortOverride: "medium",
});
const forcedResolved = await resolveMasterAgentExecutionConfig(
"master-agent",
"17600003315",
"深度理解当前项目进度",
"deep_task",
);
assert.equal(forcedResolved.model, "gpt-4.1");
assert.equal(forcedResolved.reasoningEffort, "medium");
assert.equal(forcedResolved.modelPolicy.mode, "manual_override");
});
test("主 Agent 执行配置会合成管理员提示词、用户提示词和当前对话提示词", async () => {
await saveAiAccount({
accountId: "master-codex-primary",

View File

@@ -8,7 +8,9 @@ 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 getProjectAgentControls: (typeof import("../src/lib/boss-data"))["getProjectAgentControls"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let updateDevice: (typeof import("../src/lib/boss-data"))["updateDevice"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let AUTH_SESSION_COOKIE = "";
@@ -30,7 +32,9 @@ async function setup() {
POST = messageRoute.POST;
saveAiAccount = data.saveAiAccount;
getProjectAgentControls = data.getProjectAgentControls;
updateProjectAgentControls = data.updateProjectAgentControls;
updateDevice = data.updateDevice;
readState = data.readState;
createAuthSession = data.createAuthSession;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
@@ -77,6 +81,530 @@ test.beforeEach(async () => {
await mkdir(runtimeRoot, { recursive: true });
});
test("master-agent 明确查询可用模型时直接本地返回模型清单而不进入异步队列", async () => {
await saveAiAccount({
accountId: "openai-model-list",
label: "OpenAI 主账号",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主账号",
model: "gpt-5.4",
apiKey: "sk-openai-model-list",
enabled: true,
setActive: true,
loginStatusNote: "用于模型清单测试。",
});
await saveAiAccount({
accountId: "qwen-model-list",
label: "Qwen 备用",
role: "backup",
provider: "aliyun_qwen_api",
displayName: "阿里百炼",
model: "qwen3.5-plus",
apiKey: "sk-qwen-model-list",
enabled: true,
setActive: false,
loginStatusNote: "用于模型清单测试。",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "主 Agent现在有哪些模型可以用",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent model list reply to be persisted");
assert.match(reply?.body ?? "", /当前可用模型/);
assert.match(reply?.body ?? "", /gpt-5\.4/);
assert.match(reply?.body ?? "", /qwen3\.5-plus/);
});
test("master-agent 明确要求切快模型时直接更新 controls 并返回完成态", async () => {
await saveAiAccount({
accountId: "openai-fast-switch",
label: "OpenAI 快模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 快模型",
model: "gpt-5.4-mini",
apiKey: "sk-openai-fast-switch",
enabled: true,
setActive: true,
loginStatusNote: "用于快模型切换测试。",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "帮我把快模型切到 gpt-5.4-mini",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.fastModelOverride ?? null, "gpt-5.4-mini");
const state = await readState();
assert.equal(state.masterAgentTasks.length, 0);
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent model switch reply to be persisted");
assert.match(reply?.body ?? "", /快模型/);
assert.match(reply?.body ?? "", /gpt-5\.4-mini/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4-mini");
});
test("master-agent 切换模型成功时不会因为当前在线账号为空就回报可用模型暂无", async () => {
await saveAiAccount({
accountId: "master-codex-offline-switch",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "offline-master-node",
nodeLabel: "离线 Codex",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "用于离线主节点模型切换文案测试。",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "把主agent模型换成gpt5.4-mini",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.modelOverride ?? null, "gpt-5.4-mini");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent offline switch reply to be persisted");
assert.match(reply?.body ?? "", /已把主 Agent 的当前主模型切到 gpt-5\.4-mini/);
assert.doesNotMatch(reply?.body ?? "", /当前可用模型:暂无/);
assert.match(reply?.body ?? "", /已登记\/可选模型:/);
});
test("master-agent 识别自然写法的模型名并切当前主模型", async () => {
await saveAiAccount({
accountId: "openai-main-switch",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4",
apiKey: "sk-openai-main-switch",
enabled: true,
setActive: true,
loginStatusNote: "用于主模型自然写法切换测试。",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "把主agent模型换成gpt5.4",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.modelOverride ?? null, "gpt-5.4");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent natural model switch reply to be persisted");
assert.match(reply?.body ?? "", /当前主模型/);
assert.match(reply?.body ?? "", /gpt-5\.4/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4");
});
test("master-agent 查询当前是什么大模型时直接走 fast path 返回当前模型摘要", async () => {
await saveAiAccount({
accountId: "openai-fast-query",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4",
apiKey: "sk-openai-fast-query",
enabled: true,
setActive: true,
loginStatusNote: "用于当前模型查询测试。",
});
await updateProjectAgentControls(
"master-agent",
{
fastModelOverride: "gpt-5.4-mini",
smartModelOverride: "gpt-5.4",
},
"17600003315",
);
const response = await POST(
await createAuthedRequest("master-agent", {
body: "你现在是什么大模型",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent fast model summary reply to be persisted");
assert.match(reply?.body ?? "", /当前聊天模型gpt-5\.4-mini/);
assert.match(reply?.body ?? "", /强模型gpt-5\.4/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4-mini");
});
test("master-agent 查询当前后端时直接走 fast path 返回后端摘要", async () => {
await saveAiAccount({
accountId: "openai-backend-query",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4",
apiKey: "sk-openai-backend-query",
enabled: true,
setActive: true,
loginStatusNote: "用于后端查询测试。",
});
await updateProjectAgentControls(
"master-agent",
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
const response = await POST(
await createAuthedRequest("master-agent", {
body: "当前后端是什么",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the master-agent backend summary reply to be persisted");
assert.match(reply?.body ?? "", /当前后端hermes-runtime/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4");
});
test("master-agent 查询全局接管状态时直接走 fast path 返回当前状态", async () => {
await saveAiAccount({
accountId: "openai-takeover-status",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4-mini",
apiKey: "sk-openai-takeover-status",
enabled: true,
setActive: true,
loginStatusNote: "用于全局接管状态查询测试。",
});
await updateProjectAgentControls(
"master-agent",
{
globalTakeoverEnabled: true,
},
"17600003315",
);
const response = await POST(
await createAuthedRequest("master-agent", {
body: "当前有没有开启主agent接管",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply);
assert.match(reply?.body ?? "", /全局接管:开启/);
assert.equal(reply?.senderLabel ?? "", "主Agent·gpt-5.4-mini");
});
test("master-agent 可以直接通过 fast path 开启全局接管", async () => {
await saveAiAccount({
accountId: "openai-takeover-switch",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4-mini",
apiKey: "sk-openai-takeover-switch",
enabled: true,
setActive: true,
loginStatusNote: "用于全局接管切换测试。",
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "帮我开启全局接管",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.globalTakeoverEnabled ?? null, true);
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply);
assert.match(reply?.body ?? "", /已开启全局接管/);
});
test("master-agent 可以直接通过 fast path 切换默认后端到 Hermes", async () => {
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
await saveAiAccount({
accountId: "openai-backend-switch",
label: "OpenAI 主模型",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主模型",
model: "gpt-5.4",
apiKey: "sk-openai-backend-switch",
enabled: true,
setActive: true,
loginStatusNote: "用于后端切换测试。",
});
try {
const response = await POST(
await createAuthedRequest("master-agent", {
body: "把默认后端切到 Hermes",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const controls = await getProjectAgentControls("master-agent", "17600003315");
assert.equal(controls?.backendOverride ?? null, "hermes-runtime");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply);
assert.match(reply?.body ?? "", /已把默认后端切到 hermes-runtime/i);
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
}
});
test("master-agent 查询默认执行模式时直接返回 GUI CLI 状态", async () => {
await saveAiAccount({
accountId: "master-codex-execution-mode",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "mac-studio",
nodeLabel: "Mac Studio",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "用于执行模式查询测试。",
});
await updateDevice("mac-studio", {
status: "online",
preferredExecutionMode: "gui",
capabilities: {
gui: {
connected: true,
},
cli: {
connected: true,
},
},
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "现在默认走 GUI 还是 CLI",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply);
assert.match(reply?.body ?? "", /默认执行模式gui/i);
assert.match(reply?.body ?? "", /GUI在线/);
assert.match(reply?.body ?? "", /CLI在线/);
});
test("master-agent 查询当前主节点设备状态时直接返回绑定设备在线信息", async () => {
await saveAiAccount({
accountId: "master-codex-device-status",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "mac-studio",
nodeLabel: "Mac Studio",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "用于绑定设备状态查询测试。",
});
await updateDevice("mac-studio", {
status: "online",
capabilities: {
gui: {
connected: true,
},
cli: {
connected: false,
},
},
});
const response = await POST(
await createAuthedRequest("master-agent", {
body: "当前主节点在线吗",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply);
assert.match(reply?.body ?? "", /当前主节点设备:/);
assert.match(reply?.body ?? "", /设备状态online/);
assert.match(reply?.body ?? "", /GUI在线/);
assert.match(reply?.body ?? "", /CLI离线/);
});
test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => {
await saveAiAccount({
accountId: "openai-master-agent-queue",
@@ -122,6 +650,7 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
const payload = (await response.json()) as {
ok: boolean;
message: { id: string };
task?: { taskId: string; taskType: string; status: string } | null;
masterReplyState?: "queued" | "running" | "completed";
masterReply?: { accountId?: string } | null;
@@ -134,6 +663,7 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
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");
assert.equal((payload.task as { requestMessageId?: string } | null)?.requestMessageId, payload.message.id);
await waitFor(async () => {
const state = await readState();
@@ -333,6 +863,96 @@ test("master-agent enqueue 在显式选择 claw-runtime 时会通过 Claw 异步
}
});
test("master-agent enqueue 在显式选择 hermes-runtime 时会通过 Hermes 异步回写回复", async () => {
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-hermes-queue-"));
const hermesScriptPath = path.join(hermesDir, "hermes-runtime.mjs");
await writeFile(
hermesScriptPath,
`
const args = process.argv.slice(2);
const queryIndex = args.findIndex((item) => item === "-q" || item === "--query");
const query = queryIndex >= 0 ? args[queryIndex + 1] ?? "" : "";
process.stdout.write("Hermes 已接管当前主 Agent 会话:" + query + "\\n\\n");
process.stdout.write("session_id: hermes-session-123\\n");
`,
"utf8",
);
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
await saveAiAccount({
accountId: "master-codex-primary-hermes",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "Mac 上的 Master Codex Node",
nodeId: "local-codex-node",
nodeLabel: "本机 Codex",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "用于 Hermes backend 队列测试。",
});
await updateProjectAgentControls("master-agent", {
backendOverride: "hermes-runtime",
});
try {
const response = await POST(
await createAuthedRequest("master-agent", {
body: "请走 Hermes runtime",
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; status: string } | null;
masterReply?: { accountId?: string } | null;
masterReplyState?: string | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.masterReply?.accountId, "hermes-runtime");
assert.equal(payload.masterReplyState, "queued");
assert.ok(payload.task?.taskId);
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?.status, "completed");
assert.match(task?.replyBody ?? "", /Hermes 已接管当前主 Agent 会话:/);
assert.match(task?.replyBody ?? "", /请走 Hermes runtime/);
assert.equal(task?.sessionId, "hermes-session-123");
const masterProject = nextState.projects.find((project) => project.id === "master-agent");
const mirroredReply = masterProject?.messages.at(-1);
assert.match(mirroredReply?.body ?? "", /Hermes 已接管当前主 Agent 会话/);
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
await rm(hermesDir, { recursive: true, force: true });
}
});
test("master-agent enqueue 在首选主节点离线时会回退到可用的备用主节点并返回实际账号", async () => {
await saveAiAccount({
accountId: "master-codex-primary-offline",

View File

@@ -153,6 +153,19 @@ test("master-agent 记忆页会返回当前用户所有项目记忆", async () =
}),
}),
);
await getUserMasterMemoriesRoute.POST(
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/memories", {
method: "POST",
headers: adminRequest.headers,
body: JSON.stringify({
scope: "project",
projectId: "boss-console",
title: "Boss 进度",
content: "Boss 项目聊天主链已接通。",
memoryType: "project_progress",
}),
}),
);
await getUserMasterMemoriesRoute.POST(
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/memories", {
method: "POST",
@@ -183,7 +196,7 @@ test("master-agent 记忆页会返回当前用户所有项目记忆", async () =
assert.equal(payload.ok, true);
assert.deepEqual(
payload.memories.project.map((memory) => memory.projectId).sort(),
["boss-console", "master-agent", "wenshenapp"].sort(),
["master-agent", "wenshenapp"].sort(),
);
});
@@ -276,3 +289,37 @@ test("prompt-profile 会返回当前 Claw Runtime 的可用性状态", async ()
reasonLabel: "Claw Runtime 当前未启用。",
});
});
test("prompt-profile 会返回当前 Hermes Runtime 的可用性状态", async () => {
await setup();
const memberRequest = await createAuthedRequest("18800001111", "member");
const response = await promptProfileRoute.GET(
new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/prompt-profile", {
method: "GET",
headers: memberRequest.headers,
}),
{ params: Promise.resolve({ projectId: "master-agent" }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
hermesAvailability?: {
configured: boolean;
status: string;
selectable: boolean;
reason: string;
reasonLabel: string;
};
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.hermesAvailability, {
command: "hermes",
configured: false,
status: "disabled",
selectable: false,
reason: "disabled",
reasonLabel: "Hermes Runtime 当前未启用。",
});
});

View File

@@ -14,6 +14,8 @@ let listUserMasterMemories: (typeof import("../src/lib/boss-data"))["listUserMas
let createUserMasterMemory: (typeof import("../src/lib/boss-data"))["createUserMasterMemory"];
let updateUserMasterMemory: (typeof import("../src/lib/boss-data"))["updateUserMasterMemory"];
let archiveUserMasterMemory: (typeof import("../src/lib/boss-data"))["archiveUserMasterMemory"];
let stateFile = "";
let stateBackupFile = "";
async function setup() {
if (runtimeRoot) return;
@@ -21,6 +23,8 @@ async function setup() {
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-prompts-memory-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
stateFile = process.env.BOSS_STATE_FILE;
stateBackupFile = `${stateFile}.bak`;
const data = await import("../src/lib/boss-data.ts");
readState = data.readState;
@@ -34,6 +38,21 @@ async function setup() {
archiveUserMasterMemory = data.archiveUserMasterMemory;
}
async function resetState() {
if (!stateFile) {
return;
}
await Promise.all([
rm(stateFile, { force: true }),
rm(stateBackupFile, { force: true }),
]);
}
test.beforeEach(async () => {
await setup();
await resetState();
});
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
@@ -85,3 +104,27 @@ test("主 Agent 提示词与用户记忆可读写", async () => {
assert.equal(archived?.archived, true);
assert.equal((await readState()).masterAgentMemories.length, 1);
});
test("主 Agent 项目记忆不会在状态归一化时误删 boss-console 作用域", async () => {
await setup();
const created = await createUserMasterMemory({
account: "17600003315",
scope: "project",
projectId: "boss-console",
title: "boss 项目进度",
content: "boss 项目当前按项目聚合加线程下钻展示。",
memoryType: "project_progress",
tags: ["boss", "会话"],
});
assert.equal(created.projectId, "boss-console");
const all = await listUserMasterMemories("17600003315", { includeArchived: true });
assert.equal(all.length, 1);
assert.equal(all[0]?.projectId, "boss-console");
const state = await readState();
assert.equal(state.masterAgentMemories.length, 1);
assert.equal(state.masterAgentMemories[0]?.projectId, "boss-console");
});

View File

@@ -17,4 +17,69 @@ test("project chat page listens to conversation updates for realtime refresh", a
/"conversation\.updated"/,
"expected project chat page to refresh when conversation.updated is emitted",
);
assert.match(
source,
/const warningMap = new Map<string, typeof detail\.executionWarnings\[number\]>\(\);/,
"expected project chat page to build a per-message warning map from executionWarnings",
);
assert.match(
source,
/detail\.conversationTasks\.find\(\(task\) => task\.requestMessageId === message\.id\)/,
"expected project chat page to bind lightweight conversation tasks to each message",
);
assert.match(
source,
/messageTask \? \(/,
"expected project chat page to render a compact per-message task status strip",
);
assert.match(
source,
/new Map<string, typeof detail\.executionWarnings\[number\]>\(\)/,
"expected project chat page to dedupe repeated warnings per message before rendering",
);
assert.match(
source,
/dedupedWarnings\.map\(\(warning\) => \(/,
"expected project chat page to render deduped warnings instead of the raw warning list",
);
assert.match(
source,
/detail\.conversationTasks\.length \?/,
"expected project chat page to keep a task status summary when lightweight conversation tasks exist",
);
assert.match(
source,
/resolveDispatchPlanComposerState\(detail\.dispatchPlans\)/,
"expected project chat page to derive dispatch plan composer state directly from project detail payload",
);
assert.doesNotMatch(
source,
/listDispatchPlansByProject/,
"expected project chat page to avoid a separate dispatch plan read outside project detail payload",
);
assert.match(
source,
/detail\.participantsPayload && detail\.participantsPayload\.repairRequired/,
"expected project chat page to surface a repair card when group participants are invalid",
);
assert.match(
source,
/修复群成员/,
"expected project chat page to show a visible repair members affordance",
);
assert.match(
source,
/detail\.participantsPayload\.repairReason/,
"expected project chat page to render the server-provided repair reason copy",
);
assert.match(
source,
/detail\.participantsPayload\.participants\.filter\(\(participant\) => participant\.status !== "active"\)/,
"expected project chat page to surface the concrete invalid group members instead of only a generic repair flag",
);
assert.match(
source,
/participant\.statusLabel \?\? participant\.status/,
"expected project chat page to show each invalid participant status label",
);
});

View File

@@ -0,0 +1,260 @@
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 getProjectRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let AUTH_SESSION_COOKIE = "";
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
async function setup() {
if (runtimeRoot) return;
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-project-detail-route-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [projectRouteModule, data, auth] = await Promise.all([
import("../src/app/api/v1/projects/[projectId]/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
]);
getProjectRoute = projectRouteModule.GET;
createAuthSession = data.createAuthSession;
readState = data.readState;
writeState = data.writeState;
baseState = structuredClone(await readState());
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
test.beforeEach(async () => {
await setup();
await writeState(structuredClone(baseState));
});
function buildSingleThreadProject(projectId: string) {
return {
id: projectId,
name: "项目详情线程",
pinned: false,
systemPinned: false,
deviceIds: ["device-project-detail"],
preview: "等待详情刷新。",
updatedAt: "2026-04-14T14:00:00+08:00",
lastMessageAt: "2026-04-14T14:00:00+08:00",
isGroup: false,
threadMeta: {
projectId,
threadId: "thread-project-detail",
threadDisplayName: "项目详情线程",
folderName: "Boss",
activityIconCount: 0,
updatedAt: "2026-04-14T14:00:00+08:00",
codexThreadRef: "thread-project-detail",
codexFolderRef: "boss",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development" as const,
approvalState: "not_required" as const,
unreadCount: 0,
riskLevel: "low" as const,
messages: [
{
id: "project-detail-message-1",
sender: "assistant",
senderLabel: "Codex",
body: "项目详情页需要展示任务状态。",
kind: "text" as const,
sentAt: "2026-04-14T14:00:00+08:00",
},
],
goals: [],
versions: [],
};
}
async function createAuthedRequest(projectId: string) {
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}`, {
method: "GET",
headers: {
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
});
}
test("GET /api/v1/projects/[projectId] includes lightweight conversation tasks and execution warnings", async () => {
await setup();
const state = await readState();
const project = buildSingleThreadProject("project-detail");
await writeState({
...state,
devices: state.devices.concat({
id: "device-project-detail",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
source: "production",
status: "online",
projects: [project.id],
quota5h: 0,
quota7d: 0,
lastSeenAt: "2026-04-14T14:00:00+08:00",
note: "",
}),
projects: state.projects.concat(project),
masterAgentTasks: state.masterAgentTasks.concat(
{
taskId: "task-project-detail-1",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "project-detail-message-1",
requestText: "项目详情页需要展示任务状态。",
executionPrompt: "请继续回复。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
targetThreadDisplayName: "项目详情线程",
status: "completed",
requestedAt: "2026-04-14T14:00:01+08:00",
completedAt: "2026-04-14T14:00:05+08:00",
replyBody: "Hermes 已完成回复。",
requestId: "req-project-detail-1",
sessionId: "session-project-detail-1",
},
{
taskId: "task-project-detail-hidden",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "missing-message-id",
requestText: "不应暴露",
executionPrompt: "内部同步",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
targetThreadDisplayName: "项目详情线程",
status: "completed",
requestedAt: "2026-04-14T14:00:02+08:00",
completedAt: "2026-04-14T14:00:06+08:00",
replyBody: "内部同步回复。",
sessionId: "session-project-detail-hidden",
},
),
threadExecutionWarnings: state.threadExecutionWarnings.concat(
{
warningId: "warning-project-detail-1",
taskId: "task-project-detail-1",
requestMessageId: "project-detail-message-1",
projectId: project.id,
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
sessionId: "session-project-detail-1",
requestId: "req-project-detail-1",
title: "上下文接近上限",
summary: "建议尽快压缩当前线程上下文。",
createdAt: "2026-04-14T14:00:06+08:00",
},
{
warningId: "warning-project-detail-other",
taskId: "task-other",
requestMessageId: "other-message",
projectId: "other-project",
targetProjectId: "other-project",
targetThreadId: "thread-other",
sessionId: "session-other",
requestId: "req-other",
title: "其他线程提醒",
summary: "不应进入当前项目详情。",
createdAt: "2026-04-14T14:00:07+08:00",
},
),
});
const response = await getProjectRoute(
await createAuthedRequest(project.id),
{ params: Promise.resolve({ projectId: project.id }) },
);
assert.equal(response.status, 200);
assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0");
const payload = (await response.json()) as {
ok: boolean;
conversationTasks: Array<{
taskId: string;
requestMessageId: string;
status: string;
requestId?: string;
sessionId?: string;
targetProjectId?: string;
targetThreadId?: string;
}>;
executionWarnings: Array<{
warningId: string;
taskId: string;
requestMessageId: string;
requestId?: string;
sessionId?: string;
targetProjectId?: string;
targetThreadId?: string;
title: string;
summary: string;
createdAt: string;
}>;
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.conversationTasks, [
{
taskId: "task-project-detail-1",
requestMessageId: "project-detail-message-1",
status: "completed",
requestId: "req-project-detail-1",
sessionId: "session-project-detail-1",
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
},
]);
assert.deepEqual(payload.executionWarnings, [
{
warningId: "warning-project-detail-1",
taskId: "task-project-detail-1",
requestMessageId: "project-detail-message-1",
requestId: "req-project-detail-1",
sessionId: "session-project-detail-1",
targetProjectId: project.id,
targetThreadId: "thread-project-detail",
title: "上下文接近上限",
summary: "建议尽快压缩当前线程上下文。",
createdAt: "2026-04-14T14:00:06+08:00",
},
]);
});

View File

@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const testsDir = path.dirname(fileURLToPath(import.meta.url));
const appUiPath = path.join(testsDir, "../src/components/app-ui.tsx");
test("ProjectHeaderActions switches the fourth shortcut to participants for group chats", async () => {
const source = await readFile(appUiPath, "utf8");
assert.match(
source,
/export function ProjectHeaderActions\(\{ projectId, isGroup = false \}: \{ projectId: string; isGroup\?: boolean \}\)/,
"expected header actions to accept an isGroup hint",
);
assert.match(
source,
/href=\{isGroup \? `\/conversations\/\$\{projectId\}\/participants` : `\/conversations\/\$\{projectId\}\/thread-status`\}/,
"expected group chats to route the fourth shortcut to the participants page",
);
assert.match(
source,
/\{isGroup \? "成员状态" : "线程状态"\}/,
"expected the fourth shortcut label to change for group chats",
);
});

View File

@@ -137,6 +137,14 @@ test("GET /api/v1/projects/[projectId]/messages returns a lightweight chat paylo
ok: boolean;
project: { id: string; messages: Array<{ id: string }> };
devices: Array<{ id: string }>;
conversationTasks: Array<{
taskId: string;
requestMessageId: string;
status: string;
sessionId?: string;
requestId?: string;
}>;
executionWarnings: Array<unknown>;
activeThreadContexts?: unknown;
recentAppLogs?: unknown;
openFaults?: unknown;
@@ -152,11 +160,114 @@ test("GET /api/v1/projects/[projectId]/messages returns a lightweight chat paylo
payload.devices.map((device) => device.id),
["device-message-lite"],
);
assert.deepEqual(payload.conversationTasks, []);
assert.deepEqual(payload.executionWarnings, []);
assert.equal("activeThreadContexts" in payload, false);
assert.equal("recentAppLogs" in payload, false);
assert.equal("openFaults" in payload, false);
});
test("GET /api/v1/projects/[projectId]/messages includes current-project conversation task summaries with request/session ids", async () => {
await setup();
const state = await readState();
const project = buildSingleThreadProject("message-lite-tasks");
await writeState({
...state,
devices: state.devices.concat({
id: "device-message-lite",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
source: "production",
status: "online",
projects: [project.id],
quota5h: 0,
quota7d: 0,
lastSeenAt: "2026-04-10T16:20:00+08:00",
note: "",
}),
projects: state.projects.concat(project),
masterAgentTasks: state.masterAgentTasks.concat(
{
taskId: "task-message-lite-1",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "message-lite-1",
requestText: "新的消息已经到了。",
executionPrompt: "请继续回复。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
targetThreadDisplayName: "轻量消息线程",
status: "completed",
requestedAt: "2026-04-10T16:20:01+08:00",
completedAt: "2026-04-10T16:20:05+08:00",
replyBody: "Hermes 已完成回复。",
requestId: "req-message-lite-1",
sessionId: "session-message-lite-1",
},
{
taskId: "task-message-lite-hidden",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "missing-message-id",
requestText: "这条不应暴露",
executionPrompt: "内部同步",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
targetThreadDisplayName: "轻量消息线程",
status: "completed",
requestedAt: "2026-04-10T16:20:02+08:00",
completedAt: "2026-04-10T16:20:06+08:00",
replyBody: "内部同步回复",
sessionId: "session-hidden",
},
),
});
const response = await getMessagesRoute(
await createAuthedRequest(project.id),
{ params: Promise.resolve({ projectId: project.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
conversationTasks: Array<{
taskId: string;
requestMessageId: string;
status: string;
sessionId?: string;
requestId?: string;
}>;
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.conversationTasks, [
{
taskId: "task-message-lite-1",
requestMessageId: "message-lite-1",
status: "completed",
requestId: "req-message-lite-1",
sessionId: "session-message-lite-1",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
},
]);
});
test("GET /api/v1/projects/[projectId]/messages disables caching when unauthorized", async () => {
await setup();
@@ -168,3 +279,116 @@ test("GET /api/v1/projects/[projectId]/messages disables caching when unauthoriz
assert.equal(response.status, 401);
assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0");
});
test("GET /api/v1/projects/[projectId]/messages includes execution warnings keyed by request/session/task", async () => {
await setup();
const state = await readState();
const project = buildSingleThreadProject("message-lite-warnings");
await writeState({
...state,
devices: state.devices.concat({
id: "device-message-lite",
name: "Mac Studio",
avatar: "M",
account: "17600003315",
source: "production",
status: "online",
projects: [project.id],
quota5h: 0,
quota7d: 0,
lastSeenAt: "2026-04-10T16:20:00+08:00",
note: "",
}),
projects: state.projects.concat(project),
masterAgentTasks: state.masterAgentTasks.concat({
taskId: "task-message-warning-1",
projectId: project.id,
taskType: "conversation_reply",
requestMessageId: "message-lite-1",
requestText: "新的消息已经到了。",
executionPrompt: "请继续回复。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "master-agent-hermes",
accountId: "hermes-runtime",
accountLabel: "Hermes Runtime",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
targetThreadDisplayName: "轻量消息线程",
status: "completed",
requestedAt: "2026-04-10T16:20:01+08:00",
completedAt: "2026-04-10T16:20:05+08:00",
replyBody: "Hermes 已完成回复。",
requestId: "req-message-warning-1",
sessionId: "session-message-warning-1",
}),
threadExecutionWarnings: state.threadExecutionWarnings.concat(
{
warningId: "thread-warning-1",
taskId: "task-message-warning-1",
requestMessageId: "message-lite-1",
projectId: project.id,
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
sessionId: "session-message-warning-1",
requestId: "req-message-warning-1",
title: "上下文即将溢出",
summary: "本次回复已接近上下文上限,建议尽快压缩。",
createdAt: "2026-04-10T16:20:06+08:00",
},
{
warningId: "thread-warning-other",
taskId: "task-other",
requestMessageId: "other-message",
projectId: "other-project",
targetProjectId: "other-project",
targetThreadId: "thread-other",
sessionId: "session-other",
requestId: "req-other",
title: "其他线程 warning",
summary: "不应出现在当前项目。",
createdAt: "2026-04-10T16:20:07+08:00",
},
),
});
const response = await getMessagesRoute(
await createAuthedRequest(project.id),
{ params: Promise.resolve({ projectId: project.id }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
executionWarnings: Array<{
warningId: string;
taskId: string;
requestMessageId: string;
sessionId?: string;
requestId?: string;
targetProjectId?: string;
targetThreadId?: string;
title: string;
summary: string;
createdAt: string;
}>;
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.executionWarnings, [
{
warningId: "thread-warning-1",
taskId: "task-message-warning-1",
requestMessageId: "message-lite-1",
sessionId: "session-message-warning-1",
requestId: "req-message-warning-1",
targetProjectId: project.id,
targetThreadId: "thread-message-lite",
title: "上下文即将溢出",
summary: "本次回复已接近上下文上限,建议尽快压缩。",
createdAt: "2026-04-10T16:20:06+08:00",
},
]);
});

View File

@@ -33,11 +33,12 @@ test("RealtimeRefresh supports project-scoped refresh filtering", async () => {
});
test("project conversation pages wire project-scoped realtime refresh", async () => {
const [projectPage, goalsPage, versionsPage, threadStatusPage] = await Promise.all([
const [projectPage, goalsPage, versionsPage, threadStatusPage, participantsPage] = await Promise.all([
readWorkspaceFile("src/app/conversations/[projectId]/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/goals/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/versions/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/thread-status/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/participants/page.tsx"),
]);
assert.match(projectPage, /projectId=\{detail\.project\.id\}/, "expected project chat page to pass projectId into RealtimeRefresh");
@@ -46,6 +47,7 @@ test("project conversation pages wire project-scoped realtime refresh", async ()
["goals", goalsPage],
["versions", versionsPage],
["thread-status", threadStatusPage],
["participants", participantsPage],
] as const) {
assert.match(source, /<RealtimeRefresh/, `expected ${label} page to render RealtimeRefresh`);
assert.match(source, /projectId=\{projectId\}/, `expected ${label} page to scope refreshes to the current project`);

View File

@@ -61,3 +61,27 @@ test("RemoteRuntimeAdapter 不会误杀包含路径和 sandbox 描述的有效
assert.equal(normalized.status, "completed");
assert.match(normalized.replyBody ?? "", /gptpluscontrol/);
});
test("RemoteRuntimeAdapter 会透传远端 warning 列表并完成基础清洗", () => {
const normalized = normalizeRemoteExecutionResultForTesting({
status: "completed",
replyBody: "线程执行完成。",
warnings: [
{
title: "上下文接近上限",
summary: "本轮输出较长,建议尽快压缩。",
},
{
title: " ",
summary: " ",
},
],
} as never);
assert.deepEqual(normalized.warnings, [
{
title: "上下文接近上限",
summary: "本轮输出较长,建议尽快压缩。",
},
]);
});

View File

@@ -2,7 +2,7 @@ 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 { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { NextRequest } from "next/server";
let runtimeRoot = "";
@@ -11,6 +11,7 @@ let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/task
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let readState: (typeof import("../src/lib/boss-data"))["readState"];
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let AUTH_SESSION_COOKIE = "";
async function setup() {
@@ -34,6 +35,7 @@ async function setup() {
createAuthSession = data.createAuthSession;
readState = data.readState;
writeState = data.writeState;
updateProjectAgentControls = data.updateProjectAgentControls;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
}
@@ -43,6 +45,12 @@ test.after(async () => {
}
});
test.beforeEach(async () => {
await setup();
await rm(runtimeRoot, { recursive: true, force: true });
await mkdir(runtimeRoot, { recursive: true });
});
async function createAuthedRequest(url: string, method: "POST", body: unknown) {
const session = await createAuthSession({
account: "17600003315",
@@ -61,10 +69,27 @@ async function createAuthedRequest(url: string, method: "POST", body: unknown) {
});
}
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");
}
function findSingleThreadProject(
state: Awaited<ReturnType<typeof readState>>,
projectId?: string,
) {
return state.projects.find((project) => project.id !== "master-agent" && !project.isGroup);
return state.projects.find(
(project) =>
project.id !== "master-agent" &&
!project.isGroup &&
(projectId ? project.id === projectId : true),
);
}
function buildSingleThreadProject(projectId: string) {
@@ -105,19 +130,19 @@ function buildProjectFolderKey(project: ReturnType<typeof buildSingleThreadProje
return `${project.deviceIds[0]}:${folderRef}`;
}
async function ensureSingleThreadProject() {
async function ensureSingleThreadProject(projectId = "single-thread-test") {
const state = await readState();
const existing = findSingleThreadProject(state);
const existing = findSingleThreadProject(state, projectId);
if (existing) {
return existing;
}
const project = buildSingleThreadProject("single-thread-test");
const project = buildSingleThreadProject(projectId);
await writeState({
...state,
projects: state.projects.concat(project),
});
const nextState = await readState();
return findSingleThreadProject(nextState);
return findSingleThreadProject(nextState, projectId);
}
test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task for single-thread projects", async () => {
@@ -137,7 +162,8 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
const payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null;
message: { id: string };
task?: { taskId: string; taskType: string; status: string; requestMessageId: string } | null;
dispatchPlan: null;
};
@@ -146,6 +172,7 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
assert.ok(payload.task, "expected single-thread message to return a queued task");
assert.equal(payload.task?.taskType, "conversation_reply");
assert.equal(payload.task?.status, "queued");
assert.equal(payload.task?.requestMessageId, payload.message.id);
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
@@ -164,6 +191,333 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
assert.ok(!task?.executionPrompt?.includes("deviceIds:"), "thread prompt should not include device id labels");
});
test("POST /api/v1/projects/[projectId]/messages preserves default local-agent path when ordinary thread has no backend override", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "继续走默认线程回复链" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "继续走默认线程回复链",
);
assert.ok(task, "expected a queued conversation task");
assert.equal(task?.deviceId, singleProject.deviceIds[0]);
assert.equal(task?.accountId, undefined);
assert.equal(task?.accountLabel, undefined);
});
test("POST /api/v1/projects/[projectId]/messages routes ordinary thread conversation_reply to hermes-runtime when backendOverride is set", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-hermes-test");
assert.ok(singleProject, "expected a seeded single-thread project");
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-thread-hermes-route-"));
const hermesScriptPath = path.join(hermesDir, "hermes-thread-route-runtime.mjs");
await writeFile(
hermesScriptPath,
`
process.stdout.write("Hermes 路由测试已执行\\n\\n");
process.stdout.write("session_id: hermes-thread-route-123\\n");
`,
"utf8",
);
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
try {
await updateProjectAgentControls(
singleProject.id,
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请让 Hermes 接管当前线程回复" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "请让 Hermes 接管当前线程回复",
);
assert.ok(task, "expected a queued conversation task");
assert.equal(task?.deviceId, "master-agent-hermes");
assert.equal(task?.accountId, "hermes-runtime");
assert.equal(task?.accountLabel, "Hermes Runtime");
assert.equal(task?.targetProjectId, singleProject.id);
assert.equal(task?.targetThreadId, singleProject.threadMeta.threadId);
await waitFor(async () => {
const state = await readState();
const currentTask = state.masterAgentTasks.find((item) => item.taskId === task?.taskId);
return currentTask?.status === "completed";
});
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
await rm(hermesDir, { recursive: true, force: true });
}
});
test("POST /api/v1/projects/[projectId]/messages falls back to the default local-agent path when a saved hermes override is no longer available", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-hermes-fallback-test");
assert.ok(singleProject, "expected a seeded single-thread project");
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
try {
await updateProjectAgentControls(
singleProject.id,
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
delete process.env.BOSS_HERMES_ENABLED;
delete process.env.BOSS_HERMES_COMMAND;
delete process.env.BOSS_HERMES_ARGS;
delete process.env.BOSS_HERMES_TIMEOUT_MS;
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "Hermes 不可用时请回退到默认线程链路" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const task = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "Hermes 不可用时请回退到默认线程链路",
);
assert.ok(task, "expected a queued conversation task");
assert.equal(task?.deviceId, singleProject.deviceIds[0]);
assert.equal(task?.accountId, undefined);
assert.equal(task?.accountLabel, undefined);
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
}
});
test("POST /api/v1/projects/[projectId]/messages lets Hermes asynchronously complete ordinary thread replies when backendOverride is set", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-hermes-async-test");
assert.ok(singleProject, "expected a seeded single-thread project");
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-thread-hermes-queue-"));
const hermesScriptPath = path.join(hermesDir, "hermes-thread-runtime.mjs");
await writeFile(
hermesScriptPath,
`
const args = process.argv.slice(2);
const queryIndex = args.findIndex((item) => item === "-q" || item === "--query");
const query = queryIndex >= 0 ? args[queryIndex + 1] ?? "" : "";
process.stdout.write("Hermes 线程已接管:" + query + "\\n\\n");
process.stdout.write("session_id: hermes-thread-session-123\\n");
`,
"utf8",
);
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
try {
await updateProjectAgentControls(
singleProject.id,
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请让 Hermes 真正回复当前线程" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "请让 Hermes 真正回复当前线程",
);
assert.ok(task, "expected a queued Hermes conversation task");
await waitFor(async () => {
const state = await readState();
const currentTask = state.masterAgentTasks.find((item) => item.taskId === task?.taskId);
return currentTask?.status === "completed";
});
const nextState = await readState();
const completedTask = nextState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
assert.equal(completedTask?.status, "completed");
assert.match(completedTask?.replyBody ?? "", /Hermes 线程已接管:/);
assert.equal(completedTask?.sessionId, "hermes-thread-session-123");
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const mirroredReply = updatedProject?.messages.find((message) =>
message.body.includes("Hermes 线程已接管:"),
);
assert.ok(mirroredReply, "expected Hermes reply to be written back to the thread project");
assert.equal(mirroredReply?.sender, "device");
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
await rm(hermesDir, { recursive: true, force: true });
}
});
test("ordinary thread Hermes async execution blocks leaked environment diagnostics from the chat transcript", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-hermes-env-test");
assert.ok(singleProject, "expected a seeded single-thread project");
const hermesDir = await mkdtemp(path.join(os.tmpdir(), "boss-thread-hermes-env-"));
const hermesScriptPath = path.join(hermesDir, "hermes-thread-env-runtime.mjs");
await writeFile(
hermesScriptPath,
`
process.stdout.write("我不能直接把当前会话环境从只读改回可写也不能替你修改这层运行配置。cwd 我可以在命令里指向 /Users/kris/code/gptpluscontrol。\\n\\n");
process.stdout.write("session_id: hermes-thread-env-123\\n");
`,
"utf8",
);
const previousEnv = {
BOSS_HERMES_ENABLED: process.env.BOSS_HERMES_ENABLED,
BOSS_HERMES_COMMAND: process.env.BOSS_HERMES_COMMAND,
BOSS_HERMES_ARGS: process.env.BOSS_HERMES_ARGS,
BOSS_HERMES_TIMEOUT_MS: process.env.BOSS_HERMES_TIMEOUT_MS,
};
process.env.BOSS_HERMES_ENABLED = "true";
process.env.BOSS_HERMES_COMMAND = process.execPath;
process.env.BOSS_HERMES_ARGS = hermesScriptPath;
process.env.BOSS_HERMES_TIMEOUT_MS = "1000";
try {
await updateProjectAgentControls(
singleProject.id,
{
backendOverride: "hermes-runtime",
},
"17600003315",
);
const response = await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请继续推进当前线程" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
assert.equal(response.status, 200);
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "请继续推进当前线程",
);
assert.ok(task, "expected a queued Hermes conversation task");
await waitFor(async () => {
const state = await readState();
const currentTask = state.masterAgentTasks.find((item) => item.taskId === task?.taskId);
return currentTask?.status === "failed";
});
const nextState = await readState();
const failedTask = nextState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
assert.equal(failedTask?.status, "failed");
assert.match(failedTask?.errorMessage ?? "", /THREAD_ENVIRONMENT_INVALID/);
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const leakedReply = updatedProject?.messages.find((message) =>
message.body.includes("当前会话环境从只读改回可写"),
);
assert.equal(leakedReply, undefined);
const opsNotice = updatedProject?.messages.find((message) =>
message.body.includes("线程环境异常,请重新绑定到正确项目或工作目录后再试。"),
);
assert.ok(opsNotice, "expected a user-facing system notice instead of raw environment diagnostics");
} finally {
process.env.BOSS_HERMES_ENABLED = previousEnv.BOSS_HERMES_ENABLED;
process.env.BOSS_HERMES_COMMAND = previousEnv.BOSS_HERMES_COMMAND;
process.env.BOSS_HERMES_ARGS = previousEnv.BOSS_HERMES_ARGS;
process.env.BOSS_HERMES_TIMEOUT_MS = previousEnv.BOSS_HERMES_TIMEOUT_MS;
await rm(hermesDir, { recursive: true, force: true });
}
});
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the target device prefers gui mode", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
@@ -389,3 +743,73 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete blocks leaked thread env
);
assert.ok(opsNotice, "expected a user-facing system notice instead of raw environment diagnostics");
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete persists remote warnings onto execution warning records", async () => {
await setup();
const singleProject = await ensureSingleThreadProject("single-thread-warning-test");
assert.ok(singleProject, "expected a seeded single-thread project");
await postMessageRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST",
{ body: "请同步当前线程的风险点" },
),
{ params: Promise.resolve({ projectId: singleProject.id }) },
);
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.targetProjectId === singleProject.id &&
item.requestText === "请同步当前线程的风险点",
);
assert.ok(task, "expected a queued conversation_reply task");
const response = await completeMasterTaskRoute(
await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${task.taskId}/complete`,
"POST",
{
deviceId: task.deviceId,
status: "completed",
targetProjectId: singleProject.id,
targetThreadId: singleProject.threadMeta.threadId,
requestId: "req-thread-warning-1",
warnings: [
{
title: "上下文接近上限",
summary: "本轮回复过长,建议尽快压缩。",
},
{
title: " ",
summary: " ",
},
],
replyBody: "当前风险点已同步。",
},
),
{ params: Promise.resolve({ taskId: task.taskId }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const warnings = nextState.threadExecutionWarnings.filter((warning) => warning.taskId === task.taskId);
assert.deepEqual(warnings, [
{
warningId: warnings[0]?.warningId,
taskId: task.taskId,
requestMessageId: task.requestMessageId,
projectId: singleProject.id,
targetProjectId: singleProject.id,
targetThreadId: singleProject.threadMeta.threadId,
sessionId: undefined,
requestId: "req-thread-warning-1",
title: "上下文接近上限",
summary: "本轮回复过长,建议尽快压缩。",
createdAt: warnings[0]?.createdAt,
},
]);
});

View File

@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const testsDir = path.dirname(fileURLToPath(import.meta.url));
const participantsPagePath = path.join(testsDir, "../src/app/conversations/[projectId]/participants/page.tsx");
test("web group participants page renders participant status from project detail payload", async () => {
const source = await readFile(participantsPagePath, "utf8");
assert.match(
source,
/getProjectDetailView\(state, projectId, session\.account\)/,
"expected participants page to derive its state from project detail payload",
);
assert.match(
source,
/detail\.participantsPayload/,
"expected participants page to consume participantsPayload from project detail",
);
assert.match(
source,
/participant\.statusLabel \?\? participant\.status/,
"expected participants page to surface each participant status label",
);
assert.match(
source,
/repairRequired/,
"expected participants page to render repair state when the group is dirty",
);
assert.match(
source,
/GroupParticipantsRepairClient/,
"expected participants page to mount a dedicated repair client when the group needs repair",
);
assert.match(
source,
/availableThreads=\{/,
"expected participants page to pass selectable thread candidates into the repair client",
);
assert.match(
source,
/participant\.canOpenProject \? \(/,
"expected participants page to branch on whether a participant still has an openable project reference",
);
assert.match(
source,
/href=\{`\/conversations\/\$\{participant\.projectId\}`\}/,
"expected participants page to expose a direct conversation link for openable participants",
);
});

View File

@@ -0,0 +1,94 @@
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const testsDir = path.dirname(fileURLToPath(import.meta.url));
const repairClientPath = path.join(testsDir, "../src/components/group-participants-repair-client.tsx");
const participantsPagePath = path.join(testsDir, "../src/app/conversations/[projectId]/participants/page.tsx");
test("web group participants repair client handles empty candidates and disables invalid submissions", async () => {
const source = await readFile(repairClientPath, "utf8");
assert.match(
source,
/const canSubmit = !loading && availableThreads\.length >= 2 && selectedProjectIds\.size >= 2;/,
"expected repair client to derive a submit-ready state from loading, candidates, and selections",
);
assert.match(
source,
/if \(availableThreads\.length < 2\) \{\s*setMessage\("线"\);/s,
"expected repair client to show a specific empty-candidates message before attempting submit",
);
assert.match(
source,
/disabled=\{!canSubmit\}/,
"expected repair submit button to stay disabled until the request is actually valid",
);
assert.match(
source,
/availableThreads\.length \? \(/,
"expected repair client to branch between candidate list and empty-state copy",
);
assert.match(
source,
/try \{/,
"expected repair client to guard network submission with try/finally handling",
);
assert.match(
source,
/catch \(error\) \{/,
"expected repair client to surface a user-facing message when submit fetch rejects",
);
assert.match(
source,
/finally \{\s*setLoading\(false\);/s,
"expected repair client to always clear loading even after a network failure",
);
assert.match(
source,
/router\.replace\(`\/conversations\/\$\{projectId\}\/participants\?repaired=1`\);/,
"expected repair client to persist repair success via URL state before refreshing away",
);
});
test("web group participants page hides repair form when fewer than two real thread candidates remain", async () => {
const source = await readFile(participantsPagePath, "utf8");
assert.match(
source,
/const canRepairGroupMembers = availableThreads\.length >= 2;/,
"expected participants page to compute whether repair is currently possible",
);
assert.match(
source,
/participantsPayload\?\.repairRequired && canRepairGroupMembers \? \(/,
"expected participants page to only mount the repair client when at least two real thread candidates exist",
);
assert.match(
source,
/participantsPayload\?\.repairRequired && !canRepairGroupMembers \? \(/,
"expected participants page to render a fallback guidance card when repair is required but impossible",
);
assert.match(
source,
/当前设备里暂时没有足够的真实线程可用于修复群成员。/,
"expected participants page to explain why direct repair is currently unavailable",
);
assert.match(
source,
/searchParams: Promise<\{ repaired\?: string \| string\[\] \| undefined \}>;/,
"expected participants page to read repaired search params under the current Next page contract",
);
assert.match(
source,
/const repaired = \(await searchParams\)\.repaired;/,
"expected participants page to resolve repaired status from search params",
);
assert.match(
source,
/repaired === \"1\" \? \(/,
"expected participants page to show a durable success acknowledgement after repair refresh",
);
});