Compare commits
3 Commits
codex/wech
...
codex/herm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f496838ced | ||
|
|
514971bef8 | ||
|
|
39be49630f |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 当前有效实现边界
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 或匹配登录会话
|
||||
|
||||
472
docs/architecture/hermes_runtime_接入与运维指南_cn.md
Normal file
472
docs/architecture/hermes_runtime_接入与运维指南_cn.md
Normal 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
|
||||
|
||||
772
docs/architecture/hermes_对_boss_主agent长期融合评估_cn.md
Normal file
772
docs/architecture/hermes_对_boss_主agent长期融合评估_cn.md
Normal 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 个 toolset,terminal 工具支持 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 plugin;Honcho 以插件形式提供更深的用户建模和跨 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”。
|
||||
@@ -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.
|
||||
318
docs/superpowers/plans/2026-04-14-hermes-backend-mvp.md
Normal file
318
docs/superpowers/plans/2026-04-14-hermes-backend-mvp.md
Normal 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 全部接满”
|
||||
@@ -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.
|
||||
271
docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md
Normal file
271
docs/superpowers/specs/2026-04-14-hermes-backend-mvp-design.md
Normal 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 执行后端。
|
||||
@@ -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 执行模式查询
|
||||
- 覆盖主节点设备在线状态查询
|
||||
@@ -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 {
|
||||
|
||||
20
scripts/hermes-runtime-smoke.mjs
Normal file
20
scripts/hermes-runtime-smoke.mjs
Normal 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");
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
150
src/app/conversations/[projectId]/participants/page.tsx
Normal file
150
src/app/conversations/[projectId]/participants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
115
src/components/group-participants-repair-client.tsx
Normal file
115
src/components/group-participants-repair-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
219
src/lib/boss-projections-shared.ts
Normal file
219
src/lib/boss-projections-shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
112
src/lib/execution/backends/hermes-backend.ts
Normal file
112
src/lib/execution/backends/hermes-backend.ts
Normal 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;
|
||||
212
src/lib/execution/backends/hermes-config.ts
Normal file
212
src/lib/execution/backends/hermes-config.ts
Normal 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;
|
||||
152
src/lib/execution/backends/hermes-runner.ts
Normal file
152
src/lib/execution/backends/hermes-runner.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ExecutionImmediateCompletedResult {
|
||||
status: "completed";
|
||||
backendId: string;
|
||||
output: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionImmediateFailedResult {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
122
tests/android-detail-contract-unification.test.ts
Normal file
122
tests/android-detail-contract-unification.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
52
tests/android-dispatch-reply-wait.test.ts
Normal file
52
tests/android-dispatch-reply-wait.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ test("ExecutionResult 类型守卫能区分 queued 与 immediate", () => {
|
||||
status: "completed",
|
||||
backendId: "openai-api",
|
||||
output: "done",
|
||||
sessionId: "session-completed-1",
|
||||
};
|
||||
const failed: ExecutionImmediateResult = {
|
||||
status: "failed",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
127
tests/hermes-backend-config.test.ts
Normal file
127
tests/hermes-backend-config.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
131
tests/hermes-backend.test.ts
Normal file
131
tests/hermes-backend.test.ts
Normal 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
157
tests/hermes-runner.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 当前未启用。",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
260
tests/project-detail-route.test.ts
Normal file
260
tests/project-detail-route.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
28
tests/project-header-actions.test.ts
Normal file
28
tests/project-header-actions.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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: "本轮输出较长,建议尽快压缩。",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
53
tests/web-group-participants-page.test.ts
Normal file
53
tests/web-group-participants-page.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
94
tests/web-group-participants-repair-client.test.ts
Normal file
94
tests/web-group-participants-repair-client.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user