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);
|
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 {
|
public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException {
|
||||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null);
|
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);
|
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(
|
public ApiResponse updateProjectAgentControls(
|
||||||
String projectId,
|
String projectId,
|
||||||
@Nullable String modelOverride,
|
@Nullable String modelOverride,
|
||||||
@@ -263,10 +337,6 @@ public class BossApiClient {
|
|||||||
return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload);
|
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 {
|
public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException {
|
||||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null);
|
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 androidx.core.widget.ImageViewCompat;
|
||||||
import android.content.res.ColorStateList;
|
import android.content.res.ColorStateList;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public final class BossUi {
|
public final class BossUi {
|
||||||
private static final int[] AVATAR_BG_COLORS = {
|
private static final int[] AVATAR_BG_COLORS = {
|
||||||
Color.parseColor("#1EC76F"),
|
Color.parseColor("#1EC76F"),
|
||||||
@@ -1229,6 +1234,195 @@ public final class BossUi {
|
|||||||
return buildMessageBubble(context, effectiveSender, body, "发送中", true, null);
|
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) {
|
public static void applyMessageSelectionState(Context context, View messageView, boolean selected) {
|
||||||
if (messageView == null) {
|
if (messageView == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
|||||||
try {
|
try {
|
||||||
LoadedConversation loadedConversation = loadConversation();
|
LoadedConversation loadedConversation = loadConversation();
|
||||||
BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse;
|
BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse;
|
||||||
BossApiClient.ApiResponse participantsResponse = loadedConversation.participantsResponse;
|
JSONObject participantsPayload = loadedConversation.participantsPayload;
|
||||||
JSONObject threadStatusPayload = null;
|
JSONObject threadStatusPayload = null;
|
||||||
try {
|
try {
|
||||||
BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse;
|
BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse;
|
||||||
@@ -87,7 +87,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
|||||||
threadStatusPayload = null;
|
threadStatusPayload = null;
|
||||||
}
|
}
|
||||||
JSONObject finalThreadStatusPayload = threadStatusPayload;
|
JSONObject finalThreadStatusPayload = threadStatusPayload;
|
||||||
runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json, finalThreadStatusPayload));
|
runOnUiThread(() -> renderConversation(detailResponse.json, participantsPayload, finalThreadStatusPayload));
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -471,13 +471,14 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
|||||||
throw new IllegalStateException(detailResponse.message());
|
throw new IllegalStateException(detailResponse.message());
|
||||||
}
|
}
|
||||||
|
|
||||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
|
||||||
if (!participantsResponse.ok()) {
|
|
||||||
throw new IllegalStateException(participantsResponse.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId);
|
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(
|
private BossApiClient.ApiResponse saveTakeoverSettingsWithRetry(
|
||||||
@@ -548,16 +549,16 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
|||||||
|
|
||||||
private static final class LoadedConversation {
|
private static final class LoadedConversation {
|
||||||
private final BossApiClient.ApiResponse detailResponse;
|
private final BossApiClient.ApiResponse detailResponse;
|
||||||
private final BossApiClient.ApiResponse participantsResponse;
|
private final JSONObject participantsPayload;
|
||||||
private final BossApiClient.ApiResponse threadStatusResponse;
|
private final BossApiClient.ApiResponse threadStatusResponse;
|
||||||
|
|
||||||
private LoadedConversation(
|
private LoadedConversation(
|
||||||
BossApiClient.ApiResponse detailResponse,
|
BossApiClient.ApiResponse detailResponse,
|
||||||
BossApiClient.ApiResponse participantsResponse,
|
JSONObject participantsPayload,
|
||||||
BossApiClient.ApiResponse threadStatusResponse
|
BossApiClient.ApiResponse threadStatusResponse
|
||||||
) {
|
) {
|
||||||
this.detailResponse = detailResponse;
|
this.detailResponse = detailResponse;
|
||||||
this.participantsResponse = participantsResponse;
|
this.participantsPayload = participantsPayload;
|
||||||
this.threadStatusResponse = threadStatusResponse;
|
this.threadStatusResponse = threadStatusResponse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.widget.TextView;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.json.JSONArray;
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -70,9 +71,10 @@ public class GroupCreateActivity extends BossScreenActivity {
|
|||||||
runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true));
|
runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId);
|
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(sourceProjectId);
|
||||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
||||||
runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true));
|
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json, sourceProjectId);
|
||||||
|
runOnUiThread(() -> renderCreatePage(participantsPayload, conversationsResponse.json, true));
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -155,6 +157,23 @@ public class GroupCreateActivity extends BossScreenActivity {
|
|||||||
updateCreateButtonState();
|
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(
|
private View buildHeaderView(
|
||||||
boolean hasSourceProject,
|
boolean hasSourceProject,
|
||||||
@Nullable String sourceProjectId,
|
@Nullable String sourceProjectId,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class GroupInfoActivity extends BossScreenActivity {
|
|||||||
|
|
||||||
private String projectId;
|
private String projectId;
|
||||||
private String projectName;
|
private String projectName;
|
||||||
|
private boolean groupRepairJustApplied;
|
||||||
private @Nullable BossRealtimeClient realtimeClient;
|
private @Nullable BossRealtimeClient realtimeClient;
|
||||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||||
|
|
||||||
@@ -72,13 +73,12 @@ public class GroupInfoActivity extends BossScreenActivity {
|
|||||||
try {
|
try {
|
||||||
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
|
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
|
||||||
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
||||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
|
||||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
|
||||||
BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId);
|
BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId);
|
||||||
JSONObject orchestrationBackend = orchestrationResponse.ok()
|
JSONObject orchestrationBackend = orchestrationResponse.ok()
|
||||||
? orchestrationResponse.json
|
? orchestrationResponse.json
|
||||||
: buildFallbackOrchestrationBackendPayload(orchestrationResponse.message());
|
: buildFallbackOrchestrationBackendPayload(orchestrationResponse.message());
|
||||||
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json, orchestrationBackend));
|
runOnUiThread(() -> renderGroup(detailResponse.json, participantsPayload, orchestrationBackend));
|
||||||
} catch (Exception error) {
|
} catch (Exception error) {
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
setRefreshing(false);
|
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) {
|
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
|
||||||
renderGroup(detail, participantsPayload, null);
|
renderGroup(detail, participantsPayload, null);
|
||||||
}
|
}
|
||||||
@@ -174,6 +179,11 @@ public class GroupInfoActivity extends BossScreenActivity {
|
|||||||
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
|
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
|
||||||
configureScreen("群资料", buildSubtitle(folderName, participantCount));
|
configureScreen("群资料", buildSubtitle(folderName, participantCount));
|
||||||
|
|
||||||
|
if (groupRepairJustApplied) {
|
||||||
|
appendContent(BossUi.buildEmptyCard(this, "群成员已更新,当前群聊已经切换到新的真实线程成员。"));
|
||||||
|
groupRepairJustApplied = false;
|
||||||
|
}
|
||||||
|
|
||||||
appendContent(BossUi.buildSimpleProfileHeader(
|
appendContent(BossUi.buildSimpleProfileHeader(
|
||||||
this,
|
this,
|
||||||
projectName,
|
projectName,
|
||||||
@@ -367,6 +377,7 @@ public class GroupInfoActivity extends BossScreenActivity {
|
|||||||
BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds);
|
BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds);
|
||||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
|
groupRepairJustApplied = true;
|
||||||
showMessage("群成员已更新");
|
showMessage("群成员已更新");
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,10 +62,26 @@ public final class ProjectChatUiState {
|
|||||||
public static final class ReplyWaitSpec {
|
public static final class ReplyWaitSpec {
|
||||||
public final boolean shouldWait;
|
public final boolean shouldWait;
|
||||||
public final String baselineMessageId;
|
public final String baselineMessageId;
|
||||||
|
public final List<String> executionIds;
|
||||||
|
|
||||||
private ReplyWaitSpec(boolean shouldWait, @Nullable String baselineMessageId) {
|
private ReplyWaitSpec(
|
||||||
this.shouldWait = shouldWait && !isBlank(baselineMessageId);
|
boolean shouldWait,
|
||||||
this.baselineMessageId = this.shouldWait ? baselineMessageId.trim() : "";
|
@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) {
|
public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) {
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
return new ReplyWaitSpec(false, null);
|
return new ReplyWaitSpec(false, null, null);
|
||||||
}
|
}
|
||||||
JSONObject task = response.optJSONObject("task");
|
JSONObject task = response.optJSONObject("task");
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return new ReplyWaitSpec(false, null);
|
return new ReplyWaitSpec(false, null, null);
|
||||||
}
|
}
|
||||||
String taskStatus = task.optString("status", "");
|
String taskStatus = task.optString("status", "");
|
||||||
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
|
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
|
||||||
return new ReplyWaitSpec(false, null);
|
return new ReplyWaitSpec(false, null, null);
|
||||||
}
|
}
|
||||||
JSONObject message = response.optJSONObject("message");
|
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) {
|
public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) {
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
return new ReplyWaitSpec(false, null);
|
return new ReplyWaitSpec(false, null, null);
|
||||||
}
|
}
|
||||||
JSONArray executions = response.optJSONArray("executions");
|
JSONArray executions = response.optJSONArray("executions");
|
||||||
if (executions == null || executions.length() == 0) {
|
if (executions == null || executions.length() == 0) {
|
||||||
return new ReplyWaitSpec(false, null);
|
return new ReplyWaitSpec(false, null, null);
|
||||||
}
|
}
|
||||||
JSONObject notice = response.optJSONObject("notice");
|
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) {
|
public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) {
|
||||||
@@ -457,6 +524,24 @@ public final class ProjectChatUiState {
|
|||||||
return messageId.isEmpty() ? null : messageId;
|
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) {
|
private static boolean isBlank(@Nullable String value) {
|
||||||
return value == null || value.trim().isEmpty();
|
return value == null || value.trim().isEmpty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import java.io.File;
|
|||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -55,6 +56,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
private String projectFolderName;
|
private String projectFolderName;
|
||||||
private @Nullable String currentAgentModelOverride;
|
private @Nullable String currentAgentModelOverride;
|
||||||
private @Nullable String currentReasoningEffortOverride;
|
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 quickActionsLayout;
|
||||||
private LinearLayout composerRow;
|
private LinearLayout composerRow;
|
||||||
private LinearLayout multiSelectActionsLayout;
|
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
|
@Override
|
||||||
protected int getLayoutResId() {
|
protected int getLayoutResId() {
|
||||||
return R.layout.activity_project_chat;
|
return R.layout.activity_project_chat;
|
||||||
@@ -273,7 +299,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
BossWindowInsets.applyKeyboardAvoidingInset(composerRow);
|
BossWindowInsets.applyKeyboardAvoidingInset(composerRow);
|
||||||
BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout);
|
BossWindowInsets.applyKeyboardAvoidingInset(multiSelectActionsLayout);
|
||||||
|
|
||||||
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
|
updateProjectHeader(buildDisplayedProjectTitle(initialProjectName), "正在同步项目详情...");
|
||||||
if (composerAttachmentButton != null) {
|
if (composerAttachmentButton != null) {
|
||||||
composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet());
|
composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet());
|
||||||
}
|
}
|
||||||
@@ -371,12 +397,19 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
if (projectMessagesPayload == null) {
|
if (projectMessagesPayload == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (shouldBypassRealtimeMessagesPatchForGroupState()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
JSONArray executionWarnings = projectMessagesPayload.optJSONArray("executionWarnings");
|
||||||
if (trySkipUnchangedRealtimeMessagesPatch(projectMessagesPayload)) {
|
if (trySkipUnchangedRealtimeMessagesPatch(projectMessagesPayload)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (tryAppendRealtimeMessagesPatch(projectMessagesPayload)) {
|
if (tryAppendRealtimeMessagesPatch(projectMessagesPayload)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (tryPatchRealtimeExecutionWarnings(projectMessagesPayload)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
if (reloadInFlight) {
|
if (reloadInFlight) {
|
||||||
scheduleRealtimeReload(false);
|
scheduleRealtimeReload(false);
|
||||||
@@ -389,6 +422,16 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
return true;
|
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) {
|
private boolean trySkipUnchangedRealtimeMessagesPatch(JSONObject projectMessagesPayload) {
|
||||||
if (currentRenderedProjectPayload == null || projectMessagesPayload == null) {
|
if (currentRenderedProjectPayload == null || projectMessagesPayload == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -412,6 +455,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
if (!TextUtils.equals(currentMessages.toString(), nextMessages.toString())) {
|
if (!TextUtils.equals(currentMessages.toString(), nextMessages.toString())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!hasMatchingExecutionWarnings(currentRenderedProjectPayload, projectMessagesPayload)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!hasMatchingConversationTasks(currentRenderedProjectPayload, projectMessagesPayload)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
currentRenderedProjectPayload = copyJson(projectMessagesPayload);
|
currentRenderedProjectPayload = copyJson(projectMessagesPayload);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -435,6 +484,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
if (currentMessages == null || nextMessages == null) {
|
if (currentMessages == null || nextMessages == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!hasMatchingExecutionWarnings(currentRenderedProjectPayload, projectMessagesPayload)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!hasMatchingConversationTasks(currentRenderedProjectPayload, projectMessagesPayload)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
List<String> currentIds = collectMessageIds(currentMessages);
|
List<String> currentIds = collectMessageIds(currentMessages);
|
||||||
List<String> nextIds = collectMessageIds(nextMessages);
|
List<String> nextIds = collectMessageIds(nextMessages);
|
||||||
if (currentIds.isEmpty() || nextIds.size() <= currentIds.size()) {
|
if (currentIds.isEmpty() || nextIds.size() <= currentIds.size()) {
|
||||||
@@ -460,7 +515,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", projectCollaborationMode);
|
projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", projectCollaborationMode);
|
||||||
projectApprovalState = project == null ? "not_required" : project.optString("approvalState", projectApprovalState);
|
projectApprovalState = project == null ? "not_required" : project.optString("approvalState", projectApprovalState);
|
||||||
lightDispatchReminderEnabled = project != null && project.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled);
|
lightDispatchReminderEnabled = project != null && project.optBoolean("lightDispatchReminderEnabled", lightDispatchReminderEnabled);
|
||||||
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
|
updateProjectHeader(buildDisplayedProjectTitle(title), buildProjectSubtitle(projectFolderName, devices));
|
||||||
|
|
||||||
selectionState = ProjectChatUiState.reconcileSelection(selectionState, nextIds);
|
selectionState = ProjectChatUiState.reconcileSelection(selectionState, nextIds);
|
||||||
renderNearBottom = isChatNearBottom();
|
renderNearBottom = isChatNearBottom();
|
||||||
@@ -483,6 +538,112 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
return true;
|
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) {
|
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||||
pruneRecentRealtimeEvents(now);
|
pruneRecentRealtimeEvents(now);
|
||||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||||
@@ -654,6 +815,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
JSONObject agentControls = project == null ? null : project.optJSONObject("agentControls");
|
JSONObject agentControls = project == null ? null : project.optJSONObject("agentControls");
|
||||||
currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null));
|
currentAgentModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("modelOverride", null));
|
||||||
currentReasoningEffortOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("reasoningEffortOverride", 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) {
|
if (dispatchPlans != null) {
|
||||||
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
|
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
|
||||||
currentRejectedDispatchPlan = currentPendingDispatchPlan == null
|
currentRejectedDispatchPlan = currentPendingDispatchPlan == null
|
||||||
@@ -667,7 +832,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
? currentParticipantsPayload
|
? currentParticipantsPayload
|
||||||
: participantsPayload;
|
: participantsPayload;
|
||||||
conversationInfoReady = project != null;
|
conversationInfoReady = project != null;
|
||||||
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
|
updateProjectHeader(buildDisplayedProjectTitle(title), buildProjectSubtitle(projectFolderName, devices));
|
||||||
|
|
||||||
renderQuickActions();
|
renderQuickActions();
|
||||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||||
@@ -1251,40 +1416,116 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
if (!isMasterAgentConversation()) {
|
if (!isMasterAgentConversation()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final String[] options = buildMasterAgentModelOptions();
|
setRefreshing(true);
|
||||||
int checkedIndex = findCheckedIndex(options, currentAgentModelOverride);
|
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)
|
new AlertDialog.Builder(this)
|
||||||
.setTitle("模型")
|
.setTitle("模型")
|
||||||
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
|
.setItems(new CharSequence[]{
|
||||||
|
availableLabel,
|
||||||
|
buildMasterAgentModelScopeSummary("当前主模型", currentAgentModelOverride),
|
||||||
|
buildMasterAgentModelScopeSummary("快模型", currentFastModelOverride),
|
||||||
|
buildMasterAgentModelScopeSummary("强模型", currentSmartModelOverride)
|
||||||
|
}, (dialog, which) -> {
|
||||||
if (which == 0) {
|
if (which == 0) {
|
||||||
dialog.dismiss();
|
dialog.dismiss();
|
||||||
updateMasterAgentControls(null, currentReasoningEffortOverride, "模型已恢复默认");
|
showMasterAgentAvailableModelsDialog();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (which == options.length - 1) {
|
if (which == 1) {
|
||||||
dialog.dismiss();
|
dialog.dismiss();
|
||||||
showCustomMasterAgentModelDialog();
|
showMasterAgentModelValuePicker("当前主模型", currentAgentModelOverride, "manual_override");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dialog.dismiss();
|
if (which == 2) {
|
||||||
updateMasterAgentControls(options[which], currentReasoningEffortOverride, "模型已更新为 " + options[which]);
|
dialog.dismiss();
|
||||||
|
showMasterAgentModelValuePicker("快模型", currentFastModelOverride, "fast");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (which == 3) {
|
||||||
|
dialog.dismiss();
|
||||||
|
showMasterAgentModelValuePicker("强模型", currentSmartModelOverride, "smart");
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.setNegativeButton("取消", null)
|
.setNegativeButton("取消", null)
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showCustomMasterAgentModelDialog() {
|
private void showMasterAgentAvailableModelsDialog() {
|
||||||
final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
|
String message = currentAvailableMasterAgentModels.isEmpty()
|
||||||
input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4" : currentAgentModelOverride);
|
? "当前没有检测到已就绪账号,你仍然可以直接选择预设模型,或者用自定义模型手动填写。"
|
||||||
|
: "当前可用模型:\n" + TextUtils.join("\n", currentAvailableMasterAgentModels);
|
||||||
|
String selectable = currentSelectableMasterAgentModels.isEmpty()
|
||||||
|
? ""
|
||||||
|
: "\n\n可选模型:\n" + TextUtils.join("\n", currentSelectableMasterAgentModels);
|
||||||
new AlertDialog.Builder(this)
|
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)
|
.setView(input)
|
||||||
.setNegativeButton("取消", null)
|
.setNegativeButton("取消", null)
|
||||||
.setPositiveButton("保存", (dialog, which) ->
|
.setPositiveButton("保存", (dialog, which) ->
|
||||||
updateMasterAgentControls(
|
updateMasterAgentControlsForScope(
|
||||||
|
scopeKey,
|
||||||
normalizeControlValue(input.getText() == null ? null : input.getText().toString()),
|
normalizeControlValue(input.getText() == null ? null : input.getText().toString()),
|
||||||
currentReasoningEffortOverride,
|
scopeTitle + "已更新"
|
||||||
"模型已更新"
|
|
||||||
))
|
))
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
@@ -1303,6 +1544,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
updateMasterAgentControls(
|
updateMasterAgentControls(
|
||||||
currentAgentModelOverride,
|
currentAgentModelOverride,
|
||||||
reasoningOverride,
|
reasoningOverride,
|
||||||
|
currentFastModelOverride,
|
||||||
|
currentFastReasoningEffortOverride,
|
||||||
|
currentSmartModelOverride,
|
||||||
|
currentSmartReasoningEffortOverride,
|
||||||
which == 0 ? "推理强度已恢复默认" : "推理强度已更新为 " + options[which]
|
which == 0 ? "推理强度已恢复默认" : "推理强度已更新为 " + options[which]
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -1313,6 +1558,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
private void updateMasterAgentControls(
|
private void updateMasterAgentControls(
|
||||||
@Nullable String modelOverride,
|
@Nullable String modelOverride,
|
||||||
@Nullable String reasoningEffortOverride,
|
@Nullable String reasoningEffortOverride,
|
||||||
|
@Nullable String fastModelOverride,
|
||||||
|
@Nullable String fastReasoningEffortOverride,
|
||||||
|
@Nullable String smartModelOverride,
|
||||||
|
@Nullable String smartReasoningEffortOverride,
|
||||||
String successMessage
|
String successMessage
|
||||||
) {
|
) {
|
||||||
if (!isMasterAgentConversation() || projectId == null || projectId.isEmpty()) {
|
if (!isMasterAgentConversation() || projectId == null || projectId.isEmpty()) {
|
||||||
@@ -1324,15 +1573,19 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls(
|
BossApiClient.ApiResponse response = apiClient.updateProjectAgentControls(
|
||||||
projectId,
|
projectId,
|
||||||
modelOverride,
|
modelOverride,
|
||||||
reasoningEffortOverride
|
reasoningEffortOverride,
|
||||||
|
fastModelOverride,
|
||||||
|
fastReasoningEffortOverride,
|
||||||
|
smartModelOverride,
|
||||||
|
smartReasoningEffortOverride,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
if (!response.ok()) {
|
if (!response.ok()) {
|
||||||
throw new IllegalStateException(response.message());
|
throw new IllegalStateException(response.message());
|
||||||
}
|
}
|
||||||
JSONObject controls = response.json.optJSONObject("controls");
|
JSONObject controls = response.json.optJSONObject("controls");
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
currentAgentModelOverride = normalizeControlValue(controls == null ? null : controls.optString("modelOverride", null));
|
applyMasterAgentControlsFromJson(controls);
|
||||||
currentReasoningEffortOverride = normalizeControlValue(controls == null ? null : controls.optString("reasoningEffortOverride", null));
|
|
||||||
showMessage(successMessage);
|
showMessage(successMessage);
|
||||||
reload(true);
|
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<>();
|
List<String> options = new ArrayList<>();
|
||||||
options.add("沿用默认");
|
options.add("沿用默认");
|
||||||
if (!TextUtils.isEmpty(currentAgentModelOverride)) {
|
if (!TextUtils.isEmpty(currentValue) && !options.contains(currentValue)) {
|
||||||
options.add(currentAgentModelOverride);
|
options.add(currentValue);
|
||||||
|
}
|
||||||
|
for (String value : currentSelectableMasterAgentModels) {
|
||||||
|
if (!TextUtils.isEmpty(value) && !options.contains(value)) {
|
||||||
|
options.add(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!options.contains("gpt-5.4")) {
|
if (!options.contains("gpt-5.4")) {
|
||||||
options.add("gpt-5.4");
|
options.add("gpt-5.4");
|
||||||
}
|
}
|
||||||
if (!options.contains("gpt-5.1")) {
|
if (!options.contains("gpt-5.4-mini")) {
|
||||||
options.add("gpt-5.1");
|
options.add("gpt-5.4-mini");
|
||||||
}
|
}
|
||||||
if (!options.contains("gpt-4.1")) {
|
if (!options.contains("gpt-4.1")) {
|
||||||
options.add("gpt-4.1");
|
options.add("gpt-4.1");
|
||||||
}
|
}
|
||||||
|
if (!options.contains("gpt-4.1-mini")) {
|
||||||
|
options.add("gpt-4.1-mini");
|
||||||
|
}
|
||||||
options.add("自定义...");
|
options.add("自定义...");
|
||||||
return options.toArray(new String[0]);
|
return options.toArray(new String[0]);
|
||||||
}
|
}
|
||||||
@@ -1427,8 +1745,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
private View buildRepairGroupMembersView(JSONObject participantsPayload) {
|
private View buildRepairGroupMembersView(JSONObject participantsPayload) {
|
||||||
String repairReason = participantsPayload.optString("repairReason", "当前群聊里有失效线程,请先修复群成员。");
|
String repairReason = participantsPayload.optString("repairReason", "当前群聊里有失效线程,请先修复群成员。");
|
||||||
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
|
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
|
||||||
|
int validParticipantCount = participantsPayload.optInt("validParticipantCount", 0);
|
||||||
String meta = invalidParticipantCount > 0
|
String meta = invalidParticipantCount > 0
|
||||||
? "存在 " + invalidParticipantCount + " 个失效成员"
|
? "存在 " + invalidParticipantCount + " 个失效成员"
|
||||||
|
: validParticipantCount > 0
|
||||||
|
? "当前仅有 " + validParticipantCount + " 个真实线程成员"
|
||||||
: "当前群聊还没有可下发的真实线程";
|
: "当前群聊还没有可下发的真实线程";
|
||||||
LinearLayout container = new LinearLayout(this);
|
LinearLayout container = new LinearLayout(this);
|
||||||
container.setOrientation(LinearLayout.VERTICAL);
|
container.setOrientation(LinearLayout.VERTICAL);
|
||||||
@@ -1612,6 +1933,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
);
|
);
|
||||||
break;
|
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);
|
bindMessageInteractions(messageView, messageId, body, messagePrimaryClick);
|
||||||
return messageView;
|
return messageView;
|
||||||
}
|
}
|
||||||
@@ -2491,26 +2821,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
if (!detailResponse.ok()) {
|
if (!detailResponse.ok()) {
|
||||||
throw new IllegalStateException(detailResponse.message());
|
throw new IllegalStateException(detailResponse.message());
|
||||||
}
|
}
|
||||||
JSONArray dispatchPlans = null;
|
JSONArray dispatchPlans = includeDispatchPlans
|
||||||
JSONObject participantsPayload = null;
|
? detailResponse.json.optJSONArray("dispatchPlans")
|
||||||
if (includeDispatchPlans) {
|
: null;
|
||||||
try {
|
JSONObject participantsPayload = includeDispatchPlans
|
||||||
BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId);
|
? detailResponse.json.optJSONObject("participantsPayload")
|
||||||
if (dispatchPlansResponse.ok()) {
|
: null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new ProjectSnapshot(detailResponse.json, dispatchPlans, participantsPayload);
|
return new ProjectSnapshot(detailResponse.json, dispatchPlans, participantsPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2535,7 +2851,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
updateComposerSendButtonState();
|
updateComposerSendButtonState();
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
showMessage(waitingMessage);
|
showMessage(waitingMessage);
|
||||||
enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans);
|
enqueueReplyWaitPoll(waitSpec, includeDispatchPlans);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startMasterAgentReplyWait(
|
private void startMasterAgentReplyWait(
|
||||||
@@ -2551,15 +2867,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
showMessage(waitingMessage);
|
showMessage(waitingMessage);
|
||||||
reload(true);
|
reload(true);
|
||||||
enqueueReplyWaitPoll(waitSpec.baselineMessageId, includeDispatchPlans);
|
enqueueReplyWaitPoll(waitSpec, includeDispatchPlans);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) {
|
protected void enqueueReplyWaitPoll(ProjectChatUiState.ReplyWaitSpec waitSpec, boolean includeDispatchPlans) {
|
||||||
replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans));
|
replyWaitExecutor.execute(() -> pollUntilReply(waitSpec, includeDispatchPlans));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pollUntilReply(
|
private void pollUntilReply(
|
||||||
@Nullable String baselineMessageId,
|
ProjectChatUiState.ReplyWaitSpec waitSpec,
|
||||||
boolean includeDispatchPlans
|
boolean includeDispatchPlans
|
||||||
) {
|
) {
|
||||||
long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS;
|
long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS;
|
||||||
@@ -2568,7 +2884,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) {
|
while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) {
|
||||||
ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans);
|
ProjectSnapshot snapshot = fetchProjectSnapshot(includeDispatchPlans);
|
||||||
JSONObject project = snapshot.payload.optJSONObject("project");
|
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) {
|
if (!renderedInitialSnapshot || hasReply) {
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
@@ -2633,7 +2951,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
showMessage("已重新开始等待主 Agent 回复");
|
showMessage("已重新开始等待主 Agent 回复");
|
||||||
reload(true);
|
reload(true);
|
||||||
enqueueReplyWaitPoll(masterAgentReplyBaselineMessageId, false);
|
enqueueReplyWaitPoll(
|
||||||
|
ProjectChatUiState.replyWaitFromBaseline(masterAgentReplyBaselineMessageId),
|
||||||
|
false
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static ChromeBindings buildChromeBindings(
|
static ChromeBindings buildChromeBindings(
|
||||||
@@ -2718,6 +3039,148 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
|||||||
return ids;
|
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) {
|
private JSONObject copyJson(@Nullable JSONObject source) {
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
return new JSONObject();
|
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
|
@Test
|
||||||
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
|
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
|
||||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
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(
|
ReflectionHelpers.setField(
|
||||||
activity,
|
activity,
|
||||||
@@ -32,7 +39,7 @@ public class BossUiRootSurfaceTest {
|
|||||||
);
|
);
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
|
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
|
||||||
|
|
||||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
|
||||||
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount());
|
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount());
|
||||||
|
|
||||||
View header = content.getChildAt(0);
|
View header = content.getChildAt(0);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import org.robolectric.annotation.Config;
|
|||||||
import org.robolectric.shadows.ShadowDialog;
|
import org.robolectric.shadows.ShadowDialog;
|
||||||
import org.robolectric.util.ReflectionHelpers;
|
import org.robolectric.util.ReflectionHelpers;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner.class)
|
@RunWith(RobolectricTestRunner.class)
|
||||||
@Config(sdk = 34)
|
@Config(sdk = 34)
|
||||||
public class ConversationFolderActivityTest {
|
public class ConversationFolderActivityTest {
|
||||||
@@ -145,7 +147,7 @@ public class ConversationFolderActivityTest {
|
|||||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
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);
|
assertEquals(1, activity.reloadCount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package com.hyzq.boss;
|
|||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.runner.RunWith;
|
import org.junit.runner.RunWith;
|
||||||
import org.robolectric.Robolectric;
|
import org.robolectric.Robolectric;
|
||||||
import org.robolectric.RobolectricTestRunner;
|
import org.robolectric.RobolectricTestRunner;
|
||||||
|
import org.robolectric.Shadows;
|
||||||
import org.robolectric.annotation.Config;
|
import org.robolectric.annotation.Config;
|
||||||
import org.robolectric.util.ReflectionHelpers;
|
import org.robolectric.util.ReflectionHelpers;
|
||||||
|
|
||||||
@@ -18,18 +21,27 @@ public class MainActivityConversationAutoRefreshTest {
|
|||||||
org.robolectric.android.controller.ActivityController<MainActivity> controller =
|
org.robolectric.android.controller.ActivityController<MainActivity> controller =
|
||||||
Robolectric.buildActivity(MainActivity.class).setup().resume();
|
Robolectric.buildActivity(MainActivity.class).setup().resume();
|
||||||
MainActivity activity = controller.get();
|
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, "showContent");
|
||||||
|
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||||
|
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||||
|
ReflectionHelpers.ClassParameter.from(boolean.class, false));
|
||||||
|
|
||||||
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||||
|
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||||
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
|
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
|
||||||
ReflectionHelpers.ClassParameter.from(boolean.class, false));
|
ReflectionHelpers.ClassParameter.from(boolean.class, false));
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||||
|
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||||
ReflectionHelpers.ClassParameter.from(boolean.class, false));
|
ReflectionHelpers.ClassParameter.from(boolean.class, false));
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||||
|
|
||||||
controller.pause();
|
controller.pause();
|
||||||
|
|||||||
@@ -15,15 +15,35 @@ import org.robolectric.annotation.Config;
|
|||||||
import org.robolectric.util.ReflectionHelpers;
|
import org.robolectric.util.ReflectionHelpers;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.function.BooleanSupplier;
|
import java.util.function.BooleanSupplier;
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner.class)
|
@RunWith(RobolectricTestRunner.class)
|
||||||
@Config(sdk = 34)
|
@Config(sdk = 34)
|
||||||
public class MainActivityRealtimeTest {
|
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
|
@Test
|
||||||
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
|
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"handleRealtimeEvent",
|
||||||
@@ -32,7 +52,7 @@ public class MainActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
|
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(1, activity.conversationRefreshCount);
|
assertEquals(1, activity.conversationRefreshCount);
|
||||||
assertEquals(0, activity.deviceRefreshCount);
|
assertEquals(0, activity.deviceRefreshCount);
|
||||||
@@ -42,7 +62,8 @@ public class MainActivityRealtimeTest {
|
|||||||
@Test
|
@Test
|
||||||
public void devicesRealtimeEventDoesNotRefreshConversationTab() throws Exception {
|
public void devicesRealtimeEventDoesNotRefreshConversationTab() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"handleRealtimeEvent",
|
||||||
@@ -51,7 +72,7 @@ public class MainActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
|
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(0, activity.conversationRefreshCount);
|
assertEquals(0, activity.conversationRefreshCount);
|
||||||
assertEquals(0, activity.deviceRefreshCount);
|
assertEquals(0, activity.deviceRefreshCount);
|
||||||
@@ -60,7 +81,8 @@ public class MainActivityRealtimeTest {
|
|||||||
@Test
|
@Test
|
||||||
public void blankProjectIdConversationEventDoesNotRefreshVisibleConversationTab() throws Exception {
|
public void blankProjectIdConversationEventDoesNotRefreshVisibleConversationTab() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"handleRealtimeEvent",
|
||||||
@@ -69,7 +91,7 @@ public class MainActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("conversation.updated", new JSONObject())
|
new BossRealtimeEvent("conversation.updated", new JSONObject())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(0, activity.conversationRefreshCount);
|
assertEquals(0, activity.conversationRefreshCount);
|
||||||
}
|
}
|
||||||
@@ -77,7 +99,8 @@ public class MainActivityRealtimeTest {
|
|||||||
@Test
|
@Test
|
||||||
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
|
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"handleRealtimeEvent",
|
||||||
@@ -86,7 +109,7 @@ public class MainActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("deviceId", "mac-studio"))
|
new BossRealtimeEvent("conversation.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(1, activity.conversationRefreshCount);
|
assertEquals(1, activity.conversationRefreshCount);
|
||||||
assertEquals(0, activity.deviceRefreshCount);
|
assertEquals(0, activity.deviceRefreshCount);
|
||||||
@@ -95,7 +118,8 @@ public class MainActivityRealtimeTest {
|
|||||||
@Test
|
@Test
|
||||||
public void contextIndicatorEventRefreshesVisibleConversationTab() throws Exception {
|
public void contextIndicatorEventRefreshesVisibleConversationTab() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"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);
|
assertEquals(0, activity.deviceRefreshCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void contextIndicatorSnapshotWithoutProjectIdRefreshesVisibleConversationTab() throws Exception {
|
public void contextIndicatorSnapshotWithoutProjectIdRefreshesVisibleConversationTab() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"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);
|
assertEquals(0, activity.deviceRefreshCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
|
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"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);
|
assertEquals(0, activity.deviceRefreshCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void devicesRealtimeEventRefreshesVisibleDevicesTabOnly() throws Exception {
|
public void devicesRealtimeEventRefreshesVisibleDevicesTabOnly() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"setActiveTab",
|
"setActiveTab",
|
||||||
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
|
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
|
||||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||||
);
|
);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"handleRealtimeEvent",
|
||||||
@@ -184,7 +212,7 @@ public class MainActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
|
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(0, activity.conversationRefreshCount);
|
assertEquals(0, activity.conversationRefreshCount);
|
||||||
assertEquals(1, activity.deviceRefreshCount);
|
assertEquals(1, activity.deviceRefreshCount);
|
||||||
@@ -194,13 +222,15 @@ public class MainActivityRealtimeTest {
|
|||||||
@Test
|
@Test
|
||||||
public void otaRealtimeEventRefreshesVisibleMeTabOnly() throws Exception {
|
public void otaRealtimeEventRefreshesVisibleMeTabOnly() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
showConversationTab(activity);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"setActiveTab",
|
"setActiveTab",
|
||||||
ReflectionHelpers.ClassParameter.from(String.class, "me"),
|
ReflectionHelpers.ClassParameter.from(String.class, "me"),
|
||||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||||
);
|
);
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
"handleRealtimeEvent",
|
"handleRealtimeEvent",
|
||||||
@@ -209,7 +239,7 @@ public class MainActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("ota.updated", new JSONObject())
|
new BossRealtimeEvent("ota.updated", new JSONObject())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(0, activity.conversationRefreshCount);
|
assertEquals(0, activity.conversationRefreshCount);
|
||||||
assertEquals(0, activity.deviceRefreshCount);
|
assertEquals(0, activity.deviceRefreshCount);
|
||||||
@@ -219,7 +249,8 @@ public class MainActivityRealtimeTest {
|
|||||||
@Test
|
@Test
|
||||||
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
|
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
|
||||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
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.setField(activity, "rootTabRefreshInFlight", true);
|
||||||
|
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
@@ -230,7 +261,7 @@ public class MainActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
|
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
activity,
|
activity,
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import java.util.function.BooleanSupplier;
|
|||||||
@RunWith(RobolectricTestRunner.class)
|
@RunWith(RobolectricTestRunner.class)
|
||||||
@Config(sdk = 34)
|
@Config(sdk = 34)
|
||||||
public class ProjectDetailActivityRealtimeTest {
|
public class ProjectDetailActivityRealtimeTest {
|
||||||
|
private static void flushRealtimeDebounce(ProjectDetailActivity activity) {
|
||||||
|
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void matchingProjectMessageEventTriggersReload() throws Exception {
|
public void matchingProjectMessageEventTriggersReload() throws Exception {
|
||||||
Intent intent = new Intent()
|
Intent intent = new Intent()
|
||||||
@@ -43,9 +47,11 @@ public class ProjectDetailActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
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
|
@Test
|
||||||
@@ -67,7 +73,7 @@ public class ProjectDetailActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(0, activity.reloadCount);
|
assertEquals(0, activity.reloadCount);
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ public class ProjectDetailActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
|
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(0, activity.reloadCount);
|
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
|
@Test
|
||||||
@@ -156,9 +164,11 @@ public class ProjectDetailActivityRealtimeTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(1, activity.reloadCount);
|
assertEquals(1, activity.reloadCount);
|
||||||
|
assertEquals(1, activity.loadCallCount);
|
||||||
|
assertEquals(1, activity.renderCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
@Test
|
||||||
@@ -220,7 +232,7 @@ public class ProjectDetailActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
assertTrue(activity.awaitFirstLoadStarted());
|
assertTrue(activity.awaitFirstLoadStarted());
|
||||||
|
|
||||||
ReflectionHelpers.callInstanceMethod(
|
ReflectionHelpers.callInstanceMethod(
|
||||||
@@ -239,7 +251,7 @@ public class ProjectDetailActivityRealtimeTest {
|
|||||||
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
|
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
flushRealtimeDebounce(activity);
|
||||||
|
|
||||||
assertEquals(1, activity.loadCallCount);
|
assertEquals(1, activity.loadCallCount);
|
||||||
assertEquals(0, activity.renderCount);
|
assertEquals(0, activity.renderCount);
|
||||||
@@ -315,6 +327,11 @@ public class ProjectDetailActivityRealtimeTest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
|
||||||
|
return loadProjectSnapshotForRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) {
|
void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) {
|
||||||
renderCount += 1;
|
renderCount += 1;
|
||||||
|
|||||||
@@ -714,9 +714,9 @@ public class ProjectDetailActivityUiTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void enqueueReplyWaitPoll(String baselineMessageId, boolean includeDispatchPlans) {
|
protected void enqueueReplyWaitPoll(ProjectChatUiState.ReplyWaitSpec waitSpec, boolean includeDispatchPlans) {
|
||||||
replyWaitPollCount += 1;
|
replyWaitPollCount += 1;
|
||||||
lastReplyWaitBaselineMessageId = baselineMessageId;
|
lastReplyWaitBaselineMessageId = waitSpec == null ? null : waitSpec.baselineMessageId;
|
||||||
lastReplyWaitIncludeDispatchPlans = includeDispatchPlans;
|
lastReplyWaitIncludeDispatchPlans = includeDispatchPlans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
6. `docs/architecture/wechat_project_conversation_mapping_cn.md`
|
6. `docs/architecture/wechat_project_conversation_mapping_cn.md`
|
||||||
7. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
|
7. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
|
||||||
8. `prompts/codex_fullstack_build_and_deploy_prompt_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. 当前有效实现边界
|
## 3. 当前有效实现边界
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,11 @@
|
|||||||
- 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行
|
- 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行
|
||||||
- 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
|
- 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
|
||||||
- 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime,可用于本地和服务器验证 `ClawBackendAdapter`
|
- 当前仓库自带 `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 不可用时会自动回退到默认后端并返回明确原因
|
- 当前已最小接入 `OmxTeamBackendAdapter`,但默认关闭;Web 群聊详情页和原生群资料页已经可以在 `Boss Native` 与 `OMX Team` 间切换编排后端,OMX 不可用时会自动回退到默认后端并返回明确原因
|
||||||
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter` 的 `dispatch_execution` JSON 协议
|
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter` 的 `dispatch_execution` JSON 协议
|
||||||
|
|
||||||
@@ -200,6 +205,19 @@
|
|||||||
- `heartbeat / thread reply` 平时优先写轻量进展事件
|
- `heartbeat / thread reply` 平时优先写轻量进展事件
|
||||||
- 首次理解、状态变薄、长时间未刷新或主 Agent 真正接手时,才补排隐藏全量理解任务
|
- 首次理解、状态变薄、长时间未刷新或主 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 认证相关
|
### 3.2 认证相关
|
||||||
|
|
||||||
#### `POST /api/auth/send-code`
|
#### `POST /api/auth/send-code`
|
||||||
@@ -383,6 +401,8 @@
|
|||||||
- `kind`: `text | voice_intent | image_intent | video_intent`
|
- `kind`: `text | voice_intent | image_intent | video_intent`
|
||||||
- 当前行为:
|
- 当前行为:
|
||||||
- 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务
|
- 普通单线程项目当前会在写入用户消息后,继续创建 `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 保持等待真实回写使用
|
- 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用
|
||||||
- `projectId=master-agent` 且 `kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本
|
- `projectId=master-agent` 且 `kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本
|
||||||
- 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete`
|
- 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete`
|
||||||
@@ -394,19 +414,21 @@
|
|||||||
|
|
||||||
#### `GET /api/v1/projects/[projectId]/agent-controls`
|
#### `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`
|
- 未配置时返回 `controls: null`
|
||||||
|
|
||||||
#### `POST /api/v1/projects/[projectId]/agent-controls`
|
#### `POST /api/v1/projects/[projectId]/agent-controls`
|
||||||
|
|
||||||
- 用途:更新当前对话级别的 `modelOverride / reasoningEffortOverride / promptOverride / backendOverride`
|
- 用途:更新当前对话级别的模型、推理、后端与接管控制
|
||||||
- 当前约束:
|
- 当前约束:
|
||||||
- 当前只支持 `projectId=master-agent`
|
- `master-agent` 会话当前支持 `modelOverride / reasoningEffortOverride / fastModelOverride / fastReasoningEffortOverride / smartModelOverride / smartReasoningEffortOverride / promptOverride / backendOverride / globalTakeoverEnabled`
|
||||||
- 仅 `highest_admin` 可写
|
- 主 Agent 普通聊天回复会按 `fast*` 默认策略解析模型与推理强度,深度任务保留 `smart*` 默认策略入口;显式 `modelOverride / reasoningEffortOverride` 始终优先于 fast/smart 策略
|
||||||
- `backendOverride` 当前仅支持 `claw-runtime`
|
- 普通单线程会话当前只支持 `takeoverEnabled / backendOverride`
|
||||||
- 只有在 `Claw Runtime` 可用性探测通过时才允许保存 `claw-runtime`
|
- 普通单线程会话的 `backendOverride` 当前只允许 `hermes-runtime`,不开放 `claw-runtime`
|
||||||
|
- 只有在对应 Runtime 可用性探测通过时才允许保存对应覆盖
|
||||||
- 显式传 `null` 或空字符串表示清空覆盖;省略字段表示保留原值
|
- 显式传 `null` 或空字符串表示清空覆盖;省略字段表示保留原值
|
||||||
|
|
||||||
#### `GET /api/v1/projects/[projectId]/orchestration-backend`
|
#### `GET /api/v1/projects/[projectId]/orchestration-backend`
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime`
|
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime`
|
||||||
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因
|
- 如果历史上已经保存过 `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` 验证整条链
|
- 当前仓库已自带 `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 不可用时会自动回退到默认后端并明确提示原因
|
- 当前 `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
|
- 当前仓库已自带 `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 主链
|
- 当前主 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` 和清晰的人类可读提示
|
- `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 当前真实对话链路已验证通过:`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`,真实回复随后再回写消息账本
|
- 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本
|
||||||
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt
|
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride` 强制覆盖,也支持 `fastModelOverride / fastReasoningEffortOverride / smartModelOverride / smartReasoningEffortOverride` 这组策略默认值;主 Agent 普通对话默认按 fast 档选模型,深度任务可按 smart 档选模型,手动强制覆盖仍然优先级最高
|
||||||
- 当前对话级 `agentControls` 也已支持 `backendOverride`:`master-agent` 会话可在 `Claw Runtime` 可用时显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端;不可用时保存接口会直接拒绝,并返回人类可读原因
|
- 当前 `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 提示
|
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
|
||||||
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
|
- `GET /api/v1/app-logs` 当前已支持登录态分页查询
|
||||||
- `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 当前都要求有效设备 token 或匹配登录会话
|
- `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) {
|
export function buildCodexTaskExecution(config, task, outputFile) {
|
||||||
const { targetThreadRef, cwd } = resolveResumeTarget(config, task);
|
const { targetThreadRef, cwd } = resolveResumeTarget(config, task);
|
||||||
const prompt = String(task?.executionPrompt || "");
|
const prompt = String(task?.executionPrompt || "");
|
||||||
|
const taskExecutionModel = typeof task?.executionModel === "string" && task.executionModel.trim()
|
||||||
|
? task.executionModel.trim()
|
||||||
|
: null;
|
||||||
|
const effectiveModel = taskExecutionModel || config.masterAgentModel;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
targetThreadRef &&
|
targetThreadRef &&
|
||||||
@@ -247,8 +251,8 @@ export function buildCodexTaskExecution(config, task, outputFile) {
|
|||||||
"-o",
|
"-o",
|
||||||
outputFile,
|
outputFile,
|
||||||
];
|
];
|
||||||
if (config.masterAgentModel) {
|
if (effectiveModel) {
|
||||||
args.push("-m", config.masterAgentModel);
|
args.push("-m", effectiveModel);
|
||||||
}
|
}
|
||||||
args.push(targetThreadRef, prompt);
|
args.push(targetThreadRef, prompt);
|
||||||
return {
|
return {
|
||||||
@@ -269,8 +273,8 @@ export function buildCodexTaskExecution(config, task, outputFile) {
|
|||||||
"-o",
|
"-o",
|
||||||
outputFile,
|
outputFile,
|
||||||
];
|
];
|
||||||
if (config.masterAgentModel) {
|
if (effectiveModel) {
|
||||||
args.push("-m", config.masterAgentModel);
|
args.push("-m", effectiveModel);
|
||||||
}
|
}
|
||||||
args.push(prompt);
|
args.push(prompt);
|
||||||
return {
|
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;
|
replyBody?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
warnings?: Array<{
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
}>;
|
||||||
dispatchExecutionId?: string;
|
dispatchExecutionId?: string;
|
||||||
targetProjectId?: string;
|
targetProjectId?: string;
|
||||||
targetThreadId?: string;
|
targetThreadId?: string;
|
||||||
@@ -42,6 +46,7 @@ export async function POST(
|
|||||||
replyBody: normalized.replyBody,
|
replyBody: normalized.replyBody,
|
||||||
errorMessage: normalized.errorMessage,
|
errorMessage: normalized.errorMessage,
|
||||||
requestId: normalized.requestId,
|
requestId: normalized.requestId,
|
||||||
|
warnings: normalized.warnings,
|
||||||
dispatchExecutionId: normalized.dispatchExecutionId,
|
dispatchExecutionId: normalized.dispatchExecutionId,
|
||||||
targetProjectId: normalized.targetProjectId,
|
targetProjectId: normalized.targetProjectId,
|
||||||
targetThreadId: normalized.targetThreadId,
|
targetThreadId: normalized.targetThreadId,
|
||||||
|
|||||||
@@ -3,12 +3,39 @@ import { requireRequestSession } from "@/lib/boss-auth";
|
|||||||
import {
|
import {
|
||||||
getProjectAgentControls,
|
getProjectAgentControls,
|
||||||
hasPersistedProject,
|
hasPersistedProject,
|
||||||
|
listAiAccounts,
|
||||||
updateProjectAgentControls,
|
updateProjectAgentControls,
|
||||||
} from "@/lib/boss-data";
|
} from "@/lib/boss-data";
|
||||||
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
||||||
|
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
|
||||||
import { jsonNoStore } from "@/lib/api-response";
|
import { jsonNoStore } from "@/lib/api-response";
|
||||||
|
|
||||||
const reasoningEffortValues = new Set(["low", "medium", "high"]);
|
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(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -25,11 +52,14 @@ export async function GET(
|
|||||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
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),
|
getProjectAgentControls(projectId, session.account),
|
||||||
getClawBackendAvailability(),
|
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(
|
export async function POST(
|
||||||
@@ -56,6 +86,10 @@ export async function POST(
|
|||||||
const payload = body as {
|
const payload = body as {
|
||||||
modelOverride?: unknown;
|
modelOverride?: unknown;
|
||||||
reasoningEffortOverride?: unknown;
|
reasoningEffortOverride?: unknown;
|
||||||
|
fastModelOverride?: unknown;
|
||||||
|
fastReasoningEffortOverride?: unknown;
|
||||||
|
smartModelOverride?: unknown;
|
||||||
|
smartReasoningEffortOverride?: unknown;
|
||||||
promptOverride?: unknown;
|
promptOverride?: unknown;
|
||||||
backendOverride?: unknown;
|
backendOverride?: unknown;
|
||||||
takeoverEnabled?: unknown;
|
takeoverEnabled?: unknown;
|
||||||
@@ -66,6 +100,16 @@ export async function POST(
|
|||||||
payload,
|
payload,
|
||||||
"reasoningEffortOverride",
|
"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 hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride");
|
||||||
const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride");
|
const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride");
|
||||||
const hasTakeoverEnabled = Object.prototype.hasOwnProperty.call(payload, "takeoverEnabled");
|
const hasTakeoverEnabled = Object.prototype.hasOwnProperty.call(payload, "takeoverEnabled");
|
||||||
@@ -75,16 +119,24 @@ export async function POST(
|
|||||||
? new Set([
|
? new Set([
|
||||||
"modelOverride",
|
"modelOverride",
|
||||||
"reasoningEffortOverride",
|
"reasoningEffortOverride",
|
||||||
|
"fastModelOverride",
|
||||||
|
"fastReasoningEffortOverride",
|
||||||
|
"smartModelOverride",
|
||||||
|
"smartReasoningEffortOverride",
|
||||||
"promptOverride",
|
"promptOverride",
|
||||||
"backendOverride",
|
"backendOverride",
|
||||||
"globalTakeoverEnabled",
|
"globalTakeoverEnabled",
|
||||||
])
|
])
|
||||||
: new Set(["takeoverEnabled"]);
|
: new Set(["takeoverEnabled", "backendOverride"]);
|
||||||
const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key));
|
const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key));
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
!hasModelOverride &&
|
!hasModelOverride &&
|
||||||
!hasReasoningEffortOverride &&
|
!hasReasoningEffortOverride &&
|
||||||
|
!hasFastModelOverride &&
|
||||||
|
!hasFastReasoningEffortOverride &&
|
||||||
|
!hasSmartModelOverride &&
|
||||||
|
!hasSmartReasoningEffortOverride &&
|
||||||
!hasPromptOverride &&
|
!hasPromptOverride &&
|
||||||
!hasBackendOverride &&
|
!hasBackendOverride &&
|
||||||
!hasTakeoverEnabled &&
|
!hasTakeoverEnabled &&
|
||||||
@@ -110,6 +162,64 @@ export async function POST(
|
|||||||
{ status: 400 },
|
{ 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") {
|
if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") {
|
||||||
return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 });
|
return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -117,7 +227,8 @@ export async function POST(
|
|||||||
hasBackendOverride &&
|
hasBackendOverride &&
|
||||||
payload.backendOverride !== undefined &&
|
payload.backendOverride !== undefined &&
|
||||||
payload.backendOverride !== null &&
|
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 });
|
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(
|
const controls = await updateProjectAgentControls(
|
||||||
projectId,
|
projectId,
|
||||||
{
|
{
|
||||||
...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}),
|
...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}),
|
||||||
...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}),
|
...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}),
|
||||||
|
...(hasFastModelOverride ? { fastModelOverride: payload.fastModelOverride } : {}),
|
||||||
|
...(hasFastReasoningEffortOverride
|
||||||
|
? { fastReasoningEffortOverride: payload.fastReasoningEffortOverride }
|
||||||
|
: {}),
|
||||||
|
...(hasSmartModelOverride ? { smartModelOverride: payload.smartModelOverride } : {}),
|
||||||
|
...(hasSmartReasoningEffortOverride
|
||||||
|
? { smartReasoningEffortOverride: payload.smartReasoningEffortOverride }
|
||||||
|
: {}),
|
||||||
...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}),
|
...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}),
|
||||||
...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}),
|
...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}),
|
||||||
...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}),
|
...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}),
|
||||||
@@ -165,6 +294,7 @@ export async function POST(
|
|||||||
ok: true,
|
ok: true,
|
||||||
controls: controls ?? null,
|
controls: controls ?? null,
|
||||||
clawAvailability: await getClawBackendAvailability(),
|
clawAvailability: await getClawBackendAvailability(),
|
||||||
|
hermesAvailability: await getHermesBackendAvailability(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export async function POST(
|
|||||||
masterReplyState?: "queued" | "running" | "completed";
|
masterReplyState?: "queued" | "running" | "completed";
|
||||||
task?: {
|
task?: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
requestMessageId: string;
|
||||||
taskType: "conversation_reply";
|
taskType: "conversation_reply";
|
||||||
status: "queued" | "running" | "completed";
|
status: "queued" | "running" | "completed";
|
||||||
};
|
};
|
||||||
@@ -160,6 +161,7 @@ export async function POST(
|
|||||||
let task:
|
let task:
|
||||||
| {
|
| {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
requestMessageId: string;
|
||||||
taskType: "conversation_reply";
|
taskType: "conversation_reply";
|
||||||
status: "queued" | "running" | "completed";
|
status: "queued" | "running" | "completed";
|
||||||
}
|
}
|
||||||
@@ -213,6 +215,7 @@ export async function POST(
|
|||||||
});
|
});
|
||||||
task = {
|
task = {
|
||||||
taskId: queuedTask.taskId,
|
taskId: queuedTask.taskId,
|
||||||
|
requestMessageId: queuedTask.requestMessageId,
|
||||||
taskType: "conversation_reply",
|
taskType: "conversation_reply",
|
||||||
status: "queued",
|
status: "queued",
|
||||||
};
|
};
|
||||||
@@ -232,11 +235,11 @@ export async function POST(
|
|||||||
currentSessionExpiresAt: session.expiresAt,
|
currentSessionExpiresAt: session.expiresAt,
|
||||||
mode: "enqueue",
|
mode: "enqueue",
|
||||||
});
|
});
|
||||||
if (masterReply?.ok && masterReply.taskId) {
|
if (masterReply) {
|
||||||
task = masterReply.task ?? null;
|
if (masterReply.ok && masterReply.taskId) {
|
||||||
|
task = masterReply.task ?? null;
|
||||||
|
}
|
||||||
masterReplyState = masterReply.masterReplyState ?? null;
|
masterReplyState = masterReply.masterReplyState ?? null;
|
||||||
} else {
|
|
||||||
masterReplyState = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +1,28 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { requireRequestSession } from "@/lib/boss-auth";
|
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";
|
import { jsonNoStore } from "@/lib/api-response";
|
||||||
|
|
||||||
type ConversationParticipant = {
|
function mapParticipantsRepairErrorMessage(error: unknown) {
|
||||||
projectId: string;
|
if (!(error instanceof Error)) {
|
||||||
deviceId: string;
|
return "UNKNOWN_ERROR";
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const participants = project.isGroup
|
switch (error.message) {
|
||||||
? project.groupMembers.map((member) => {
|
case "GROUP_CHAT_MEMBER_NOT_FOUND":
|
||||||
const candidateProject = state.projects.find((item) => item.id === member.projectId);
|
return "有线程已经不存在,请刷新后重新选择。";
|
||||||
const device = state.devices.find((item) => item.id === member.deviceId);
|
case "GROUP_CHAT_MEMBER_NOT_THREAD":
|
||||||
const status: ConversationParticipant["status"] = !candidateProject
|
return "所选项目里包含不可下发的对象,请重新选择真实线程。";
|
||||||
? "missing_project"
|
case "GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS":
|
||||||
: isDispatchableThreadProject(candidateProject)
|
return "至少选择 2 个真实线程后才能修复群成员。";
|
||||||
? "active"
|
case "PROJECT_NOT_FOUND":
|
||||||
: "invalid_target";
|
return "当前群聊不存在或已被删除。";
|
||||||
return buildParticipant(
|
case "PROJECT_NOT_GROUP_CHAT":
|
||||||
member.projectId,
|
return "当前项目不是群聊,无法修复群成员。";
|
||||||
member.deviceId,
|
default:
|
||||||
member.threadId,
|
return error.message;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -130,7 +36,7 @@ export async function GET(
|
|||||||
|
|
||||||
const { projectId } = await context.params;
|
const { projectId } = await context.params;
|
||||||
const state = await readState();
|
const state = await readState();
|
||||||
const payload = buildParticipantsPayload(state, projectId);
|
const payload = buildProjectParticipantsPayload(state, projectId);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -162,14 +68,14 @@ export async function POST(
|
|||||||
requestedBy: session.account,
|
requestedBy: session.account,
|
||||||
});
|
});
|
||||||
const nextState = await readState();
|
const nextState = await readState();
|
||||||
const payload = buildParticipantsPayload(nextState, projectId);
|
const payload = buildProjectParticipantsPayload(nextState, projectId);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||||
}
|
}
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
{ ok: false, message: mapParticipantsRepairErrorMessage(error) },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
updateUserMasterPrompt,
|
updateUserMasterPrompt,
|
||||||
} from "@/lib/boss-data";
|
} from "@/lib/boss-data";
|
||||||
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
||||||
|
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
|
||||||
import { jsonNoStore } from "@/lib/api-response";
|
import { jsonNoStore } from "@/lib/api-response";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -27,11 +28,12 @@ export async function GET(
|
|||||||
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
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(),
|
getMasterAgentPromptPolicy(),
|
||||||
getUserMasterPrompt(session.account),
|
getUserMasterPrompt(session.account),
|
||||||
getProjectAgentControls(projectId, session.account),
|
getProjectAgentControls(projectId, session.account),
|
||||||
getClawBackendAvailability(),
|
getClawBackendAvailability(),
|
||||||
|
getHermesBackendAvailability(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return jsonNoStore({
|
return jsonNoStore({
|
||||||
@@ -42,6 +44,7 @@ export async function GET(
|
|||||||
projectControls,
|
projectControls,
|
||||||
projectPromptOverride: projectControls?.promptOverride ?? null,
|
projectPromptOverride: projectControls?.promptOverride ?? null,
|
||||||
clawAvailability,
|
clawAvailability,
|
||||||
|
hermesAvailability,
|
||||||
account: session.account,
|
account: session.account,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,6 +107,7 @@ export async function POST(
|
|||||||
&& typeof payload.backendOverride === "string"
|
&& typeof payload.backendOverride === "string"
|
||||||
&& payload.backendOverride.trim() !== ""
|
&& payload.backendOverride.trim() !== ""
|
||||||
&& payload.backendOverride.trim() !== "claw-runtime"
|
&& payload.backendOverride.trim() !== "claw-runtime"
|
||||||
|
&& payload.backendOverride.trim() !== "hermes-runtime"
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 });
|
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) {
|
if (hasUserPromptContent) {
|
||||||
const userPromptContent = typeof payload.userPromptContent === "string" ? payload.userPromptContent.trim() : "";
|
const userPromptContent = typeof payload.userPromptContent === "string" ? payload.userPromptContent.trim() : "";
|
||||||
if (userPromptContent) {
|
if (userPromptContent) {
|
||||||
@@ -143,11 +165,12 @@ export async function POST(
|
|||||||
}, session.account);
|
}, session.account);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([
|
const [promptPolicy, userPrompt, projectControls, clawAvailability, hermesAvailability] = await Promise.all([
|
||||||
getMasterAgentPromptPolicy(),
|
getMasterAgentPromptPolicy(),
|
||||||
getUserMasterPrompt(session.account),
|
getUserMasterPrompt(session.account),
|
||||||
getProjectAgentControls(projectId, session.account),
|
getProjectAgentControls(projectId, session.account),
|
||||||
getClawBackendAvailability(),
|
getClawBackendAvailability(),
|
||||||
|
getHermesBackendAvailability(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -158,6 +181,7 @@ export async function POST(
|
|||||||
projectControls,
|
projectControls,
|
||||||
projectPromptOverride: projectControls?.promptOverride ?? null,
|
projectPromptOverride: projectControls?.promptOverride ?? null,
|
||||||
clawAvailability,
|
clawAvailability,
|
||||||
|
hermesAvailability,
|
||||||
account: session.account,
|
account: session.account,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,14 +15,58 @@ import {
|
|||||||
import { requirePageSession } from "@/lib/boss-auth";
|
import { requirePageSession } from "@/lib/boss-auth";
|
||||||
import {
|
import {
|
||||||
getProjectOrchestrationBackendState,
|
getProjectOrchestrationBackendState,
|
||||||
listDispatchPlansByProject,
|
|
||||||
readState,
|
readState,
|
||||||
} from "@/lib/boss-data";
|
} from "@/lib/boss-data";
|
||||||
import { resolveDispatchPlanComposerState } from "@/lib/dispatch-plan-ui";
|
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";
|
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({
|
export default async function ProjectChatPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -32,15 +76,16 @@ export default async function ProjectChatPage({
|
|||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const state = await readState();
|
const state = await readState();
|
||||||
const detail = getProjectDetailView(state, projectId, session.account);
|
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();
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
const dispatchPlanState = detail.project.isGroup
|
||||||
|
? resolveDispatchPlanComposerState(detail.dispatchPlans)
|
||||||
|
: resolveDispatchPlanComposerState([]);
|
||||||
|
const orchestrationBackendState = detail.project.isGroup
|
||||||
|
? await getProjectOrchestrationBackendState(projectId)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell bottomNav={false}>
|
<AppShell bottomNav={false}>
|
||||||
<RealtimeRefresh
|
<RealtimeRefresh
|
||||||
@@ -89,7 +134,7 @@ export default async function ProjectChatPage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="pt-3">
|
<div className="pt-3">
|
||||||
<ProjectHeaderActions projectId={detail.project.id} />
|
<ProjectHeaderActions projectId={detail.project.id} isGroup={detail.project.isGroup} />
|
||||||
</div>
|
</div>
|
||||||
{detail.project.isGroup && orchestrationBackendState ? (
|
{detail.project.isGroup && orchestrationBackendState ? (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
@@ -99,6 +144,37 @@ export default async function ProjectChatPage({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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="mt-4 space-y-3">
|
||||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||||
<div className="text-[14px] font-semibold text-[#111111]">主 Agent 调度结论</div>
|
<div className="text-[14px] font-semibold text-[#111111]">主 Agent 调度结论</div>
|
||||||
@@ -172,9 +248,74 @@ export default async function ProjectChatPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex-1 space-y-4 overflow-y-auto pb-6">
|
<div className="mt-4 flex-1 space-y-4 overflow-y-auto pb-6">
|
||||||
{detail.project.messages.map((message) => (
|
{detail.project.messages.map((message) => {
|
||||||
<ChatBubble key={message.id} message={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]">
|
<div className="rounded-2xl bg-white px-4 py-3 text-[13px] leading-6 text-[#57606A]">
|
||||||
语音、图片、视频和转发入口已经接到当前消息账本。对象存储和真实媒体文件仍保持 MVP 占位。
|
语音、图片、视频和转发入口已经接到当前消息账本。对象存储和真实媒体文件仍保持 MVP 占位。
|
||||||
</div>
|
</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 { requirePageSession } from "@/lib/boss-auth";
|
||||||
import { readState } from "@/lib/boss-data";
|
import { readState } from "@/lib/boss-data";
|
||||||
import { getProjectDetailView } from "@/lib/boss-projections";
|
import { getProjectDetailView } from "@/lib/boss-projections";
|
||||||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MasterAgentPromptMemoryClient } from "@/components/master-agent-prompt-
|
|||||||
import { requirePageSession } from "@/lib/boss-auth";
|
import { requirePageSession } from "@/lib/boss-auth";
|
||||||
import { MASTER_AGENT_CHAT_PAGE_ANCHORS } from "@/lib/master-agent-chat-menu";
|
import { MASTER_AGENT_CHAT_PAGE_ANCHORS } from "@/lib/master-agent-chat-menu";
|
||||||
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
||||||
|
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
|
||||||
import {
|
import {
|
||||||
getMasterAgentPromptPolicy,
|
getMasterAgentPromptPolicy,
|
||||||
getProjectAgentControls,
|
getProjectAgentControls,
|
||||||
@@ -15,7 +16,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function MasterAgentPromptMemoryPage() {
|
export default async function MasterAgentPromptMemoryPage() {
|
||||||
const session = await requirePageSession();
|
const session = await requirePageSession();
|
||||||
const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability] =
|
const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability, hermesAvailability] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getMasterAgentPromptPolicy(),
|
getMasterAgentPromptPolicy(),
|
||||||
getUserMasterPrompt(session.account),
|
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: "global" }),
|
||||||
listUserMasterMemories(session.account, { includeArchived: false, scope: "project" }),
|
listUserMasterMemories(session.account, { includeArchived: false, scope: "project" }),
|
||||||
getClawBackendAvailability(),
|
getClawBackendAvailability(),
|
||||||
|
getHermesBackendAvailability(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,6 +49,7 @@ export default async function MasterAgentPromptMemoryPage() {
|
|||||||
userPrompt={userPrompt}
|
userPrompt={userPrompt}
|
||||||
projectControls={projectControls}
|
projectControls={projectControls}
|
||||||
clawAvailability={clawAvailability}
|
clawAvailability={clawAvailability}
|
||||||
|
hermesAvailability={hermesAvailability}
|
||||||
globalMemories={globalMemories}
|
globalMemories={globalMemories}
|
||||||
projectMemories={projectMemories}
|
projectMemories={projectMemories}
|
||||||
anchors={MASTER_AGENT_CHAT_PAGE_ANCHORS}
|
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 { MasterAgentTakeoverClient } from "@/components/master-agent-takeover-client";
|
||||||
import { requirePageSession } from "@/lib/boss-auth";
|
import { requirePageSession } from "@/lib/boss-auth";
|
||||||
import { getProjectAgentControls } from "@/lib/boss-data";
|
import { getProjectAgentControls } from "@/lib/boss-data";
|
||||||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
resolveAliyunQwenModelSelection,
|
resolveAliyunQwenModelSelection,
|
||||||
resolveAliyunQwenModelValue,
|
resolveAliyunQwenModelValue,
|
||||||
} from "@/lib/ai-account-models";
|
} from "@/lib/ai-account-models";
|
||||||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
|
||||||
|
|
||||||
type AccountDraft = {
|
type AccountDraft = {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
planThrottledRefresh,
|
planThrottledRefresh,
|
||||||
shouldRefreshRealtimeEvent,
|
shouldRefreshRealtimeEvent,
|
||||||
} from "@/lib/realtime-refresh";
|
} from "@/lib/realtime-refresh";
|
||||||
import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections";
|
import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections-shared";
|
||||||
import {
|
import {
|
||||||
clearNativeSessionSnapshot,
|
clearNativeSessionSnapshot,
|
||||||
currentAppLocation,
|
currentAppLocation,
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ import type {
|
|||||||
UserProfile,
|
UserProfile,
|
||||||
UserSettings,
|
UserSettings,
|
||||||
} from "@/lib/boss-data";
|
} from "@/lib/boss-data";
|
||||||
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections";
|
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections-shared";
|
||||||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
|
||||||
|
|
||||||
function formatClock(value: string) {
|
function formatClock(value: string) {
|
||||||
return formatTimestampLabel(value);
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Link
|
<Link
|
||||||
@@ -936,10 +936,10 @@ export function ProjectHeaderActions({ projectId }: { projectId: string }) {
|
|||||||
转发
|
转发
|
||||||
</Link>
|
</Link>
|
||||||
<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]"
|
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
|
||||||
>
|
>
|
||||||
线程状态
|
{isGroup ? "成员状态" : "线程状态"}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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,
|
UserMasterPrompt,
|
||||||
} from "@/lib/boss-data";
|
} from "@/lib/boss-data";
|
||||||
import type { MasterAgentChatPageAnchors } from "@/lib/master-agent-chat-menu";
|
import type { MasterAgentChatPageAnchors } from "@/lib/master-agent-chat-menu";
|
||||||
import { formatTimestampLabel } from "@/lib/boss-projections";
|
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
|
||||||
|
|
||||||
type MemoryDraft = {
|
type MemoryDraft = {
|
||||||
scope: MasterMemoryScope;
|
scope: MasterMemoryScope;
|
||||||
@@ -31,6 +31,13 @@ type ClawAvailability = {
|
|||||||
reasonLabel: string;
|
reasonLabel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HermesAvailability = {
|
||||||
|
status: "disabled" | "misconfigured" | "ready";
|
||||||
|
selectable: boolean;
|
||||||
|
reason: string;
|
||||||
|
reasonLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
const memoryScopeOptions: Array<{ value: MasterMemoryScope; label: string }> = [
|
const memoryScopeOptions: Array<{ value: MasterMemoryScope; label: string }> = [
|
||||||
{ value: "global", label: "通用记忆" },
|
{ value: "global", label: "通用记忆" },
|
||||||
{ value: "project", label: "项目记忆" },
|
{ value: "project", label: "项目记忆" },
|
||||||
@@ -153,6 +160,7 @@ export function MasterAgentPromptMemoryClient({
|
|||||||
userPrompt,
|
userPrompt,
|
||||||
projectControls,
|
projectControls,
|
||||||
clawAvailability,
|
clawAvailability,
|
||||||
|
hermesAvailability,
|
||||||
globalMemories,
|
globalMemories,
|
||||||
projectMemories,
|
projectMemories,
|
||||||
anchors,
|
anchors,
|
||||||
@@ -162,6 +170,7 @@ export function MasterAgentPromptMemoryClient({
|
|||||||
userPrompt: UserMasterPrompt | null;
|
userPrompt: UserMasterPrompt | null;
|
||||||
projectControls: ProjectAgentControls | null;
|
projectControls: ProjectAgentControls | null;
|
||||||
clawAvailability: ClawAvailability;
|
clawAvailability: ClawAvailability;
|
||||||
|
hermesAvailability: HermesAvailability;
|
||||||
globalMemories: MasterAgentMemory[];
|
globalMemories: MasterAgentMemory[];
|
||||||
projectMemories: MasterAgentMemory[];
|
projectMemories: MasterAgentMemory[];
|
||||||
anchors: MasterAgentChatPageAnchors;
|
anchors: MasterAgentChatPageAnchors;
|
||||||
@@ -175,11 +184,25 @@ export function MasterAgentPromptMemoryClient({
|
|||||||
const [reasoningEffortOverride, setReasoningEffortOverride] = useState(
|
const [reasoningEffortOverride, setReasoningEffortOverride] = useState(
|
||||||
projectControls?.reasoningEffortOverride ?? "",
|
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 [promptOverride, setPromptOverride] = useState(projectControls?.promptOverride ?? "");
|
||||||
const storedClawOverrideUnavailable =
|
const storedClawOverrideUnavailable =
|
||||||
projectControls?.backendOverride === "claw-runtime" && !clawAvailability.selectable;
|
projectControls?.backendOverride === "claw-runtime" && !clawAvailability.selectable;
|
||||||
|
const storedHermesOverrideUnavailable =
|
||||||
|
projectControls?.backendOverride === "hermes-runtime" && !hermesAvailability.selectable;
|
||||||
const [backendOverride, setBackendOverride] = useState(
|
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 [newMemory, setNewMemory] = useState<MemoryDraft>(makeNewMemoryDraft());
|
||||||
const [memoryDrafts, setMemoryDrafts] = useState<Record<string, MemoryDraft>>(() => {
|
const [memoryDrafts, setMemoryDrafts] = useState<Record<string, MemoryDraft>>(() => {
|
||||||
@@ -200,10 +223,19 @@ export function MasterAgentPromptMemoryClient({
|
|||||||
? `【执行后端】\n${backendOverride.trim()}`
|
? `【执行后端】\n${backendOverride.trim()}`
|
||||||
: storedClawOverrideUnavailable
|
: storedClawOverrideUnavailable
|
||||||
? "【执行后端】\n默认(Claw Runtime 当前不可用,运行时会自动回退)"
|
? "【执行后端】\n默认(Claw Runtime 当前不可用,运行时会自动回退)"
|
||||||
|
: storedHermesOverrideUnavailable
|
||||||
|
? "【执行后端】\n默认(Hermes Runtime 当前不可用,运行时会自动回退)"
|
||||||
: null,
|
: null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return sections.length > 0 ? sections.join("\n\n") : "当前还没有组合后的提示词内容。";
|
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) {
|
function updateMemoryDraft(memoryId: string, updater: (draft: MemoryDraft) => MemoryDraft) {
|
||||||
setMemoryDrafts((current) => ({
|
setMemoryDrafts((current) => ({
|
||||||
@@ -264,6 +296,10 @@ export function MasterAgentPromptMemoryClient({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
modelOverride: modelOverride.trim() || null,
|
modelOverride: modelOverride.trim() || null,
|
||||||
reasoningEffortOverride: reasoningEffortOverride.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,
|
promptOverride: promptOverride.trim() || null,
|
||||||
backendOverride: backendOverride.trim() || null,
|
backendOverride: backendOverride.trim() || null,
|
||||||
}),
|
}),
|
||||||
@@ -432,6 +468,7 @@ export function MasterAgentPromptMemoryClient({
|
|||||||
>
|
>
|
||||||
<option value="">默认</option>
|
<option value="">默认</option>
|
||||||
<option value="gpt-5.4">gpt-5.4</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">gpt-4.1</option>
|
||||||
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
|
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -458,6 +495,63 @@ export function MasterAgentPromptMemoryClient({
|
|||||||
>
|
>
|
||||||
<option value="">默认</option>
|
<option value="">默认</option>
|
||||||
{clawAvailability.selectable ? <option value="claw-runtime">Claw Runtime</option> : null}
|
{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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,6 +566,17 @@ export function MasterAgentPromptMemoryClient({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
<TextArea
|
||||||
label="当前对话附加提示词"
|
label="当前对话附加提示词"
|
||||||
value={promptOverride}
|
value={promptOverride}
|
||||||
|
|||||||
@@ -380,6 +380,35 @@ export interface DispatchExecution {
|
|||||||
completedByDeviceId?: string;
|
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(
|
export function buildCollaborationGate(
|
||||||
project?: Pick<Project, "isGroup" | "collaborationMode" | "approvalState" | "lightDispatchReminderEnabled">,
|
project?: Pick<Project, "isGroup" | "collaborationMode" | "approvalState" | "lightDispatchReminderEnabled">,
|
||||||
) {
|
) {
|
||||||
@@ -405,8 +434,12 @@ export function buildCollaborationGate(
|
|||||||
export interface ProjectAgentControls {
|
export interface ProjectAgentControls {
|
||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
reasoningEffortOverride?: ReasoningEffort;
|
reasoningEffortOverride?: ReasoningEffort;
|
||||||
|
fastModelOverride?: string;
|
||||||
|
fastReasoningEffortOverride?: ReasoningEffort;
|
||||||
|
smartModelOverride?: string;
|
||||||
|
smartReasoningEffortOverride?: ReasoningEffort;
|
||||||
promptOverride?: string;
|
promptOverride?: string;
|
||||||
backendOverride?: "claw-runtime";
|
backendOverride?: "claw-runtime" | "hermes-runtime";
|
||||||
takeoverEnabled?: boolean;
|
takeoverEnabled?: boolean;
|
||||||
globalTakeoverEnabled?: boolean;
|
globalTakeoverEnabled?: boolean;
|
||||||
effectiveTakeoverEnabled?: boolean;
|
effectiveTakeoverEnabled?: boolean;
|
||||||
@@ -596,6 +629,20 @@ export interface ThreadProgressEvent {
|
|||||||
sourceMessageId?: string;
|
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 {
|
export interface VerificationCode {
|
||||||
id: string;
|
id: string;
|
||||||
account: string;
|
account: string;
|
||||||
@@ -740,6 +787,8 @@ export interface MasterAgentTask {
|
|||||||
requestMessageId: string;
|
requestMessageId: string;
|
||||||
requestText: string;
|
requestText: string;
|
||||||
executionPrompt: string;
|
executionPrompt: string;
|
||||||
|
executionModel?: string;
|
||||||
|
executionReasoningEffort?: ReasoningEffort;
|
||||||
requestedBy: string;
|
requestedBy: string;
|
||||||
requestedByAccount: string;
|
requestedByAccount: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@@ -771,6 +820,7 @@ export interface MasterAgentTask {
|
|||||||
replyBody?: string;
|
replyBody?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OtaUpdate {
|
export interface OtaUpdate {
|
||||||
@@ -1019,6 +1069,7 @@ export interface BossState {
|
|||||||
deviceImportResolutions: DeviceImportResolution[];
|
deviceImportResolutions: DeviceImportResolution[];
|
||||||
threadStatusDocuments: ThreadStatusDocument[];
|
threadStatusDocuments: ThreadStatusDocument[];
|
||||||
threadProgressEvents: ThreadProgressEvent[];
|
threadProgressEvents: ThreadProgressEvent[];
|
||||||
|
threadExecutionWarnings: ThreadExecutionWarning[];
|
||||||
otaUpdates: OtaUpdate[];
|
otaUpdates: OtaUpdate[];
|
||||||
otaUpdateLogs: OtaUpdateLog[];
|
otaUpdateLogs: OtaUpdateLog[];
|
||||||
deviceSkills: DeviceSkill[];
|
deviceSkills: DeviceSkill[];
|
||||||
@@ -1663,6 +1714,7 @@ const initialState: BossState = {
|
|||||||
projectExecutionPolicies: [],
|
projectExecutionPolicies: [],
|
||||||
threadStatusDocuments: [],
|
threadStatusDocuments: [],
|
||||||
threadProgressEvents: [],
|
threadProgressEvents: [],
|
||||||
|
threadExecutionWarnings: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const levelPriority: Record<ContextBudgetLevel, number> = {
|
const levelPriority: Record<ContextBudgetLevel, number> = {
|
||||||
@@ -1876,14 +1928,19 @@ function parseReasoningEffortOverride(value: unknown) {
|
|||||||
return { kind: "set" as const, value };
|
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) {
|
if (value === undefined || value === null) {
|
||||||
return { kind: "clear" as const };
|
return { kind: "clear" as const };
|
||||||
}
|
}
|
||||||
if (value !== "claw-runtime") {
|
if (value === "claw-runtime" || value === "hermes-runtime") {
|
||||||
return { kind: "invalid" as const };
|
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) {
|
function parseBooleanControlOverride(value: unknown) {
|
||||||
@@ -2352,8 +2409,19 @@ function normalizeProjectAgentControls(
|
|||||||
const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride)
|
const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride)
|
||||||
? raw.reasoningEffortOverride
|
? raw.reasoningEffortOverride
|
||||||
: undefined;
|
: 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 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 takeoverEnabled = typeof raw?.takeoverEnabled === "boolean" ? raw.takeoverEnabled : undefined;
|
||||||
const globalTakeoverEnabled =
|
const globalTakeoverEnabled =
|
||||||
typeof raw?.globalTakeoverEnabled === "boolean" ? raw.globalTakeoverEnabled : undefined;
|
typeof raw?.globalTakeoverEnabled === "boolean" ? raw.globalTakeoverEnabled : undefined;
|
||||||
@@ -2361,6 +2429,10 @@ function normalizeProjectAgentControls(
|
|||||||
if (
|
if (
|
||||||
!modelOverride &&
|
!modelOverride &&
|
||||||
!reasoningEffortOverride &&
|
!reasoningEffortOverride &&
|
||||||
|
!fastModelOverride &&
|
||||||
|
!fastReasoningEffortOverride &&
|
||||||
|
!smartModelOverride &&
|
||||||
|
!smartReasoningEffortOverride &&
|
||||||
!promptOverride &&
|
!promptOverride &&
|
||||||
!backendOverride &&
|
!backendOverride &&
|
||||||
takeoverEnabled === undefined &&
|
takeoverEnabled === undefined &&
|
||||||
@@ -2372,6 +2444,10 @@ function normalizeProjectAgentControls(
|
|||||||
return {
|
return {
|
||||||
modelOverride,
|
modelOverride,
|
||||||
reasoningEffortOverride,
|
reasoningEffortOverride,
|
||||||
|
fastModelOverride,
|
||||||
|
fastReasoningEffortOverride,
|
||||||
|
smartModelOverride,
|
||||||
|
smartReasoningEffortOverride,
|
||||||
promptOverride,
|
promptOverride,
|
||||||
backendOverride,
|
backendOverride,
|
||||||
takeoverEnabled,
|
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) {
|
function buildHeartbeatProgressSummary(threadDisplayName: string) {
|
||||||
return `检测到线程有新活动:${threadDisplayName}`;
|
return `检测到线程有新活动:${threadDisplayName}`;
|
||||||
}
|
}
|
||||||
@@ -3237,6 +3350,16 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
|||||||
requestMessageId: task.requestMessageId ?? "",
|
requestMessageId: task.requestMessageId ?? "",
|
||||||
requestText: task.requestText ?? "",
|
requestText: task.requestText ?? "",
|
||||||
executionPrompt: task.executionPrompt ?? 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 ?? "用户",
|
requestedBy: task.requestedBy ?? "用户",
|
||||||
requestedByAccount: task.requestedByAccount ?? "",
|
requestedByAccount: task.requestedByAccount ?? "",
|
||||||
deviceId: task.deviceId ?? PRIMARY_CODEX_NODE_ID,
|
deviceId: task.deviceId ?? PRIMARY_CODEX_NODE_ID,
|
||||||
@@ -3272,6 +3395,7 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
|||||||
replyBody: task.replyBody,
|
replyBody: task.replyBody,
|
||||||
errorMessage: task.errorMessage,
|
errorMessage: task.errorMessage,
|
||||||
requestId: task.requestId,
|
requestId: task.requestId,
|
||||||
|
sessionId: task.sessionId,
|
||||||
})),
|
})),
|
||||||
dispatchPlans: ensureArray(raw.dispatchPlans, base.dispatchPlans).map((plan, index) =>
|
dispatchPlans: ensureArray(raw.dispatchPlans, base.dispatchPlans).map((plan, index) =>
|
||||||
normalizeDispatchPlan(plan, base.dispatchPlans[index % Math.max(1, base.dispatchPlans.length)]),
|
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)],
|
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) => ({
|
otaUpdates: ensureArray(raw.otaUpdates, base.otaUpdates).map((update, index) => ({
|
||||||
...base.otaUpdates[index % base.otaUpdates.length],
|
...base.otaUpdates[index % base.otaUpdates.length],
|
||||||
...update,
|
...update,
|
||||||
@@ -3455,9 +3588,6 @@ function removeLegacyBossConsoleArtifacts(state: BossState) {
|
|||||||
...device,
|
...device,
|
||||||
projects: device.projects.filter((project) => !isLegacyBossConsoleRef(project)),
|
projects: device.projects.filter((project) => !isLegacyBossConsoleRef(project)),
|
||||||
}));
|
}));
|
||||||
state.masterAgentMemories = state.masterAgentMemories.filter(
|
|
||||||
(memory) => !isLegacyBossConsoleRef(memory.projectId),
|
|
||||||
);
|
|
||||||
state.userProjectAgentControls = state.userProjectAgentControls.filter(
|
state.userProjectAgentControls = state.userProjectAgentControls.filter(
|
||||||
(item) => !isLegacyBossConsoleRef(item.projectId),
|
(item) => !isLegacyBossConsoleRef(item.projectId),
|
||||||
);
|
);
|
||||||
@@ -3968,6 +4098,11 @@ function syncDerivedState(input: BossState) {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.slice(0, 400);
|
.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
|
state.deviceSkills = state.deviceSkills
|
||||||
.filter((skill) => visibleDeviceIds.has(skill.deviceId))
|
.filter((skill) => visibleDeviceIds.has(skill.deviceId))
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
@@ -4260,6 +4395,10 @@ export async function updateProjectAgentControls(
|
|||||||
payload: {
|
payload: {
|
||||||
modelOverride?: unknown;
|
modelOverride?: unknown;
|
||||||
reasoningEffortOverride?: unknown;
|
reasoningEffortOverride?: unknown;
|
||||||
|
fastModelOverride?: unknown;
|
||||||
|
fastReasoningEffortOverride?: unknown;
|
||||||
|
smartModelOverride?: unknown;
|
||||||
|
smartReasoningEffortOverride?: unknown;
|
||||||
promptOverride?: unknown;
|
promptOverride?: unknown;
|
||||||
backendOverride?: unknown;
|
backendOverride?: unknown;
|
||||||
takeoverEnabled?: unknown;
|
takeoverEnabled?: unknown;
|
||||||
@@ -4281,6 +4420,18 @@ export async function updateProjectAgentControls(
|
|||||||
const promptOverrideInput = Object.prototype.hasOwnProperty.call(payload, "promptOverride")
|
const promptOverrideInput = Object.prototype.hasOwnProperty.call(payload, "promptOverride")
|
||||||
? parseControlTextOverride(payload.promptOverride)
|
? parseControlTextOverride(payload.promptOverride)
|
||||||
: { kind: "preserve" as const };
|
: { 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")
|
const backendOverrideInput = Object.prototype.hasOwnProperty.call(payload, "backendOverride")
|
||||||
? parseBackendOverride(payload.backendOverride)
|
? parseBackendOverride(payload.backendOverride)
|
||||||
: { kind: "preserve" as const };
|
: { kind: "preserve" as const };
|
||||||
@@ -4299,6 +4450,18 @@ export async function updateProjectAgentControls(
|
|||||||
if (promptOverrideInput.kind === "invalid") {
|
if (promptOverrideInput.kind === "invalid") {
|
||||||
throw new Error("INVALID_PROMPT_OVERRIDE");
|
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") {
|
if (backendOverrideInput.kind === "invalid") {
|
||||||
throw new Error("INVALID_BACKEND_OVERRIDE");
|
throw new Error("INVALID_BACKEND_OVERRIDE");
|
||||||
}
|
}
|
||||||
@@ -4308,16 +4471,34 @@ export async function updateProjectAgentControls(
|
|||||||
if (globalTakeoverEnabledInput.kind === "invalid") {
|
if (globalTakeoverEnabledInput.kind === "invalid") {
|
||||||
throw new Error("INVALID_GLOBAL_TAKEOVER_ENABLED");
|
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 (projectId !== "master-agent") {
|
||||||
if (
|
if (
|
||||||
modelOverrideInput.kind !== "preserve" ||
|
modelOverrideInput.kind !== "preserve" ||
|
||||||
reasoningEffortInput.kind !== "preserve" ||
|
reasoningEffortInput.kind !== "preserve" ||
|
||||||
|
fastModelOverrideInput.kind !== "preserve" ||
|
||||||
|
fastReasoningEffortInput.kind !== "preserve" ||
|
||||||
|
smartModelOverrideInput.kind !== "preserve" ||
|
||||||
|
smartReasoningEffortInput.kind !== "preserve" ||
|
||||||
promptOverrideInput.kind !== "preserve" ||
|
promptOverrideInput.kind !== "preserve" ||
|
||||||
backendOverrideInput.kind !== "preserve" ||
|
|
||||||
globalTakeoverEnabledInput.kind !== "preserve"
|
globalTakeoverEnabledInput.kind !== "preserve"
|
||||||
) {
|
) {
|
||||||
throw new Error("PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED");
|
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") {
|
} else if (takeoverEnabledInput.kind !== "preserve") {
|
||||||
throw new Error("MASTER_AGENT_TAKEOVER_SCOPE_RESTRICTED");
|
throw new Error("MASTER_AGENT_TAKEOVER_SCOPE_RESTRICTED");
|
||||||
}
|
}
|
||||||
@@ -4347,6 +4528,30 @@ export async function updateProjectAgentControls(
|
|||||||
: promptOverrideInput.kind === "clear"
|
: promptOverrideInput.kind === "clear"
|
||||||
? undefined
|
? undefined
|
||||||
: currentControls?.promptOverride;
|
: 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 =
|
const backendOverride =
|
||||||
backendOverrideInput.kind === "set"
|
backendOverrideInput.kind === "set"
|
||||||
? backendOverrideInput.value
|
? backendOverrideInput.value
|
||||||
@@ -4369,12 +4574,20 @@ export async function updateProjectAgentControls(
|
|||||||
const currentModelOverride = currentControls?.modelOverride;
|
const currentModelOverride = currentControls?.modelOverride;
|
||||||
const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride;
|
const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride;
|
||||||
const currentPromptOverride = currentControls?.promptOverride;
|
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 currentBackendOverride = currentControls?.backendOverride;
|
||||||
const currentTakeoverEnabled = currentControls?.takeoverEnabled;
|
const currentTakeoverEnabled = currentControls?.takeoverEnabled;
|
||||||
const currentGlobalTakeoverEnabled = currentControls?.globalTakeoverEnabled;
|
const currentGlobalTakeoverEnabled = currentControls?.globalTakeoverEnabled;
|
||||||
if (
|
if (
|
||||||
currentModelOverride === modelOverride &&
|
currentModelOverride === modelOverride &&
|
||||||
currentReasoningEffortOverride === reasoningEffortOverride &&
|
currentReasoningEffortOverride === reasoningEffortOverride &&
|
||||||
|
currentFastModelOverride === fastModelOverride &&
|
||||||
|
currentFastReasoningEffortOverride === fastReasoningEffortOverride &&
|
||||||
|
currentSmartModelOverride === smartModelOverride &&
|
||||||
|
currentSmartReasoningEffortOverride === smartReasoningEffortOverride &&
|
||||||
currentPromptOverride === promptOverride &&
|
currentPromptOverride === promptOverride &&
|
||||||
currentBackendOverride === backendOverride &&
|
currentBackendOverride === backendOverride &&
|
||||||
currentTakeoverEnabled === takeoverEnabled &&
|
currentTakeoverEnabled === takeoverEnabled &&
|
||||||
@@ -4394,6 +4607,10 @@ export async function updateProjectAgentControls(
|
|||||||
const nextControls = {
|
const nextControls = {
|
||||||
modelOverride,
|
modelOverride,
|
||||||
reasoningEffortOverride,
|
reasoningEffortOverride,
|
||||||
|
fastModelOverride,
|
||||||
|
fastReasoningEffortOverride,
|
||||||
|
smartModelOverride,
|
||||||
|
smartReasoningEffortOverride,
|
||||||
promptOverride,
|
promptOverride,
|
||||||
backendOverride,
|
backendOverride,
|
||||||
takeoverEnabled,
|
takeoverEnabled,
|
||||||
@@ -5710,6 +5927,8 @@ export async function queueMasterAgentTask(payload: {
|
|||||||
requestMessageId: string;
|
requestMessageId: string;
|
||||||
requestText: string;
|
requestText: string;
|
||||||
executionPrompt: string;
|
executionPrompt: string;
|
||||||
|
executionModel?: string;
|
||||||
|
executionReasoningEffort?: ReasoningEffort;
|
||||||
requestedBy: string;
|
requestedBy: string;
|
||||||
requestedByAccount: string;
|
requestedByAccount: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@@ -5734,6 +5953,7 @@ export async function queueMasterAgentTask(payload: {
|
|||||||
deviceImportCandidateFolderName?: string;
|
deviceImportCandidateFolderName?: string;
|
||||||
projectUnderstandingTargetProjectId?: string;
|
projectUnderstandingTargetProjectId?: string;
|
||||||
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
|
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
|
||||||
|
sessionId?: string;
|
||||||
}) {
|
}) {
|
||||||
const task = await mutateState((state) => {
|
const task = await mutateState((state) => {
|
||||||
const task: MasterAgentTask = {
|
const task: MasterAgentTask = {
|
||||||
@@ -5743,6 +5963,8 @@ export async function queueMasterAgentTask(payload: {
|
|||||||
requestMessageId: payload.requestMessageId,
|
requestMessageId: payload.requestMessageId,
|
||||||
requestText: payload.requestText,
|
requestText: payload.requestText,
|
||||||
executionPrompt: payload.executionPrompt,
|
executionPrompt: payload.executionPrompt,
|
||||||
|
executionModel: payload.executionModel?.trim() || undefined,
|
||||||
|
executionReasoningEffort: payload.executionReasoningEffort,
|
||||||
requestedBy: payload.requestedBy,
|
requestedBy: payload.requestedBy,
|
||||||
requestedByAccount: payload.requestedByAccount,
|
requestedByAccount: payload.requestedByAccount,
|
||||||
deviceId: payload.deviceId,
|
deviceId: payload.deviceId,
|
||||||
@@ -5767,6 +5989,7 @@ export async function queueMasterAgentTask(payload: {
|
|||||||
deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName,
|
deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName,
|
||||||
projectUnderstandingTargetProjectId: payload.projectUnderstandingTargetProjectId,
|
projectUnderstandingTargetProjectId: payload.projectUnderstandingTargetProjectId,
|
||||||
projectUnderstandingReason: payload.projectUnderstandingReason,
|
projectUnderstandingReason: payload.projectUnderstandingReason,
|
||||||
|
sessionId: payload.sessionId,
|
||||||
status: "queued",
|
status: "queued",
|
||||||
requestedAt: nowIso(),
|
requestedAt: nowIso(),
|
||||||
};
|
};
|
||||||
@@ -5886,12 +6109,111 @@ function upsertDispatchPlanInState(
|
|||||||
return plan;
|
return plan;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDispatchPlansByProject(groupProjectId: string) {
|
export function buildDispatchPlansByProject(state: BossState, groupProjectId: string): DispatchPlanWithExecutions[] {
|
||||||
const state = await readState();
|
|
||||||
const normalizedGroupProjectId = groupProjectId.trim();
|
const normalizedGroupProjectId = groupProjectId.trim();
|
||||||
return state.dispatchPlans
|
return state.dispatchPlans
|
||||||
.filter((plan) => plan.groupProjectId === normalizedGroupProjectId)
|
.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) {
|
function canOwnDispatchPlans(project: Project) {
|
||||||
@@ -6677,6 +6999,11 @@ export async function completeMasterAgentTask(payload: {
|
|||||||
replyBody?: string;
|
replyBody?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
warnings?: Array<{
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
}>;
|
||||||
dispatchExecutionId?: string;
|
dispatchExecutionId?: string;
|
||||||
targetProjectId?: string;
|
targetProjectId?: string;
|
||||||
targetThreadId?: string;
|
targetThreadId?: string;
|
||||||
@@ -6703,6 +7030,13 @@ export async function completeMasterAgentTask(payload: {
|
|||||||
task.replyBody = payload.replyBody?.trim() || undefined;
|
task.replyBody = payload.replyBody?.trim() || undefined;
|
||||||
task.errorMessage = payload.errorMessage?.trim() || undefined;
|
task.errorMessage = payload.errorMessage?.trim() || undefined;
|
||||||
task.requestId = payload.requestId;
|
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
|
const linkedAccount = task.accountId
|
||||||
? state.aiAccounts.find((item) => item.accountId === task.accountId)
|
? state.aiAccounts.find((item) => item.accountId === task.accountId)
|
||||||
: undefined;
|
: 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 {
|
return {
|
||||||
...task,
|
...task,
|
||||||
dispatchPlan: createdDispatchPlan ? { ...createdDispatchPlan } : undefined,
|
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,
|
Capability,
|
||||||
ContextBudgetLevel,
|
ContextBudgetLevel,
|
||||||
Device,
|
Device,
|
||||||
DeviceEnrollment,
|
DispatchPlanWithExecutions,
|
||||||
DeviceImportDraft,
|
|
||||||
DeviceImportResolution,
|
|
||||||
ProjectExecutionPolicy,
|
|
||||||
DeviceSkill,
|
|
||||||
MasterIdentitySummary,
|
MasterIdentitySummary,
|
||||||
MasterAgentMemory,
|
MasterAgentMemory,
|
||||||
MasterAgentPromptPolicy,
|
MasterAgentPromptPolicy,
|
||||||
@@ -22,57 +18,29 @@ import type {
|
|||||||
OpsRepairVerification,
|
OpsRepairVerification,
|
||||||
Project,
|
Project,
|
||||||
ProjectAgentControls,
|
ProjectAgentControls,
|
||||||
RiskLevel,
|
ProjectParticipantsPayload,
|
||||||
ThreadContextAlert,
|
ThreadContextAlert,
|
||||||
ThreadContextSnapshot,
|
ThreadContextSnapshot,
|
||||||
ThreadHandoffPackage,
|
ThreadHandoffPackage,
|
||||||
UserMasterPrompt,
|
UserMasterPrompt,
|
||||||
|
MasterAgentTaskStatus,
|
||||||
} from "@/lib/boss-data";
|
} from "@/lib/boss-data";
|
||||||
|
import {
|
||||||
export interface ContextIndicator {
|
buildDispatchPlansByProject,
|
||||||
visible: boolean;
|
buildProjectParticipantsPayload,
|
||||||
style: "ring_percent";
|
formatTimestampLabel,
|
||||||
percent?: number;
|
} from "@/lib/boss-projections-shared";
|
||||||
level?: ContextBudgetLevel;
|
import type {
|
||||||
}
|
ConversationItem,
|
||||||
|
DeviceWorkspaceView,
|
||||||
export interface ConversationItem {
|
SkillInventoryDeviceGroup,
|
||||||
conversationId: string;
|
} from "@/lib/boss-projections-shared";
|
||||||
conversationType: "master_agent" | "single_device" | "group" | "folder_archive";
|
export type {
|
||||||
projectId: string;
|
ContextIndicator,
|
||||||
projectTitle: string;
|
ConversationItem,
|
||||||
threadTitle: string;
|
DeviceWorkspaceView,
|
||||||
folderLabel: string;
|
SkillInventoryDeviceGroup,
|
||||||
folderKey?: string;
|
} from "@/lib/boss-projections-shared";
|
||||||
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 ThreadContextView {
|
export interface ThreadContextView {
|
||||||
snapshot: ThreadContextSnapshot;
|
snapshot: ThreadContextSnapshot;
|
||||||
@@ -84,6 +52,10 @@ export interface ProjectDetailView {
|
|||||||
project: Project;
|
project: Project;
|
||||||
agentControls?: ProjectAgentControls | null;
|
agentControls?: ProjectAgentControls | null;
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
|
conversationTasks: ConversationTaskSummary[];
|
||||||
|
executionWarnings: ExecutionWarningSummary[];
|
||||||
|
dispatchPlans: DispatchPlanWithExecutions[];
|
||||||
|
participantsPayload?: ProjectParticipantsPayload | null;
|
||||||
masterIdentity?: MasterIdentitySummary;
|
masterIdentity?: MasterIdentitySummary;
|
||||||
activeThreadContexts: ThreadContextView[];
|
activeThreadContexts: ThreadContextView[];
|
||||||
nextCompactionRiskThreadId?: string;
|
nextCompactionRiskThreadId?: string;
|
||||||
@@ -102,15 +74,6 @@ export interface ThreadContextDetailView {
|
|||||||
masterActions: string[];
|
masterActions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceWorkspaceView {
|
|
||||||
selectedDevice?: Device;
|
|
||||||
relatedThreads: ThreadContextSnapshot[];
|
|
||||||
activeEnrollment?: DeviceEnrollment;
|
|
||||||
importDraft?: DeviceImportDraft;
|
|
||||||
importResolution?: DeviceImportResolution;
|
|
||||||
projectExecutionPolicies?: ProjectExecutionPolicy[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpsSummaryView {
|
export interface OpsSummaryView {
|
||||||
mode: "active" | "idle";
|
mode: "active" | "idle";
|
||||||
faults: OpsFault[];
|
faults: OpsFault[];
|
||||||
@@ -127,11 +90,6 @@ export interface AuditSummaryView {
|
|||||||
capabilities: Capability[];
|
capabilities: Capability[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillInventoryDeviceGroup {
|
|
||||||
device: Device;
|
|
||||||
skills: DeviceSkill[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SkillInventoryView {
|
export interface SkillInventoryView {
|
||||||
boundDeviceId?: string;
|
boundDeviceId?: string;
|
||||||
groups: SkillInventoryDeviceGroup[];
|
groups: SkillInventoryDeviceGroup[];
|
||||||
@@ -150,36 +108,6 @@ const aiRolePriority: Record<AiAccountRole, number> = {
|
|||||||
api_fallback: 2,
|
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_SYNC_LABEL = "待同步";
|
||||||
const STALE_CONTEXT_REPLY_THRESHOLD_MS = 7 * 24 * 60 * 60_000;
|
const STALE_CONTEXT_REPLY_THRESHOLD_MS = 7 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
@@ -548,10 +476,103 @@ export interface ConversationFolderView {
|
|||||||
threads: ConversationItem[];
|
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 {
|
export interface ProjectMessagesRealtimePayload {
|
||||||
ok: true;
|
ok: true;
|
||||||
project: Project;
|
project: Project;
|
||||||
devices: Device[];
|
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[] {
|
export function getConversationHomeItems(state: BossState): ConversationItem[] {
|
||||||
@@ -732,6 +753,8 @@ export function buildProjectMessagesRealtimePayload(
|
|||||||
ok: true,
|
ok: true,
|
||||||
project,
|
project,
|
||||||
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
|
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,
|
project,
|
||||||
agentControls: resolveProjectAgentControls(state, projectId, account),
|
agentControls: resolveProjectAgentControls(state, projectId, account),
|
||||||
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
|
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,
|
masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined,
|
||||||
activeThreadContexts,
|
activeThreadContexts,
|
||||||
nextCompactionRiskThreadId: topRisk?.threadId,
|
nextCompactionRiskThreadId: topRisk?.threadId,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
type ClawBackendSelectionState,
|
type ClawBackendSelectionState,
|
||||||
isClawRequestKindSupported,
|
isClawRequestKindSupported,
|
||||||
} from "@/lib/execution/backends/claw-backend";
|
} from "@/lib/execution/backends/claw-backend";
|
||||||
|
import {
|
||||||
|
HERMES_BACKEND,
|
||||||
|
type HermesBackendSelectionState,
|
||||||
|
isHermesRequestKindSupported,
|
||||||
|
} from "@/lib/execution/backends/hermes-backend";
|
||||||
import {
|
import {
|
||||||
MASTER_CODEX_NODE_BACKEND,
|
MASTER_CODEX_NODE_BACKEND,
|
||||||
isReadyMasterCodexNodeBackend,
|
isReadyMasterCodexNodeBackend,
|
||||||
@@ -27,10 +32,12 @@ export interface ExecutionBackendSelectionInput {
|
|||||||
requestKind?: ExecutionRequestKind;
|
requestKind?: ExecutionRequestKind;
|
||||||
requestedBackendId?: string;
|
requestedBackendId?: string;
|
||||||
claw?: ClawBackendSelectionState;
|
claw?: ClawBackendSelectionState;
|
||||||
|
hermes?: HermesBackendSelectionState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExecutionBackendChoice =
|
export type ExecutionBackendChoice =
|
||||||
| typeof CLAW_BACKEND
|
| typeof CLAW_BACKEND
|
||||||
|
| typeof HERMES_BACKEND
|
||||||
| typeof MASTER_CODEX_NODE_BACKEND
|
| typeof MASTER_CODEX_NODE_BACKEND
|
||||||
| typeof OPENAI_BACKEND
|
| typeof OPENAI_BACKEND
|
||||||
| typeof ALIYUN_QWEN_BACKEND;
|
| typeof ALIYUN_QWEN_BACKEND;
|
||||||
@@ -57,6 +64,14 @@ function isReadyBackend(choice: ExecutionBackendChoice, input: ExecutionBackendS
|
|||||||
return isClawRequestKindSupported(requestKind);
|
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 = [
|
const candidates = [
|
||||||
...(input.primary.provider === choice.provider ? [input.primary] : []),
|
...(input.primary.provider === choice.provider ? [input.primary] : []),
|
||||||
...input.backups.filter((item) => item.provider === choice.provider),
|
...input.backups.filter((item) => item.provider === choice.provider),
|
||||||
@@ -104,6 +119,13 @@ export function listExecutionBackendChoices(
|
|||||||
pushBackend(CLAW_BACKEND);
|
pushBackend(CLAW_BACKEND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.requestedBackendId === HERMES_BACKEND.backendId &&
|
||||||
|
isReadyBackend(HERMES_BACKEND, input)
|
||||||
|
) {
|
||||||
|
pushBackend(HERMES_BACKEND);
|
||||||
|
}
|
||||||
|
|
||||||
if (input.primary.status === "ready") {
|
if (input.primary.status === "ready") {
|
||||||
pushBackend(primaryBackend);
|
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"
|
"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: {
|
export function resolveRelevantMemories(input: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
requestText?: string;
|
requestText?: string;
|
||||||
@@ -26,12 +80,13 @@ export function resolveRelevantMemories(input: {
|
|||||||
: !lowered
|
: !lowered
|
||||||
? projectScoped.slice(0, 6)
|
? projectScoped.slice(0, 6)
|
||||||
: projectScoped
|
: projectScoped
|
||||||
.filter((memory) => {
|
.map((memory) => ({
|
||||||
const haystacks = [memory.projectId, memory.title, memory.content, ...(memory.tags ?? [])]
|
memory,
|
||||||
.filter((value): value is string => Boolean(value))
|
score: scoreProjectMemoryMatch(memory, lowered),
|
||||||
.map((value) => value.toLowerCase());
|
}))
|
||||||
return haystacks.some((value) => lowered.includes(value) || value.includes(lowered));
|
.filter((entry) => entry.score > 0)
|
||||||
})
|
.sort((left, right) => right.score - left.score)
|
||||||
|
.map((entry) => entry.memory)
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
|
|
||||||
const userMemories = input.memories.filter((memory) => memory.scope === "global").slice(0, 8);
|
const userMemories = input.memories.filter((memory) => memory.scope === "global").slice(0, 8);
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface RemoteExecutionResultInput {
|
|||||||
replyBody?: string;
|
replyBody?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
warnings?: Array<{
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedRemoteExecutionResult {
|
export interface NormalizedRemoteExecutionResult {
|
||||||
@@ -18,6 +22,10 @@ export interface NormalizedRemoteExecutionResult {
|
|||||||
replyBody?: string;
|
replyBody?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
warnings?: Array<{
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimToDefined(value: string | undefined) {
|
function trimToDefined(value: string | undefined) {
|
||||||
@@ -56,12 +64,28 @@ function buildThreadEnvironmentErrorMessage() {
|
|||||||
return "THREAD_ENVIRONMENT_INVALID: 线程返回了内部环境提示,已拦截,请检查线程绑定或工作目录。";
|
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(
|
export function normalizeRemoteExecutionResult(
|
||||||
input: RemoteExecutionResultInput,
|
input: RemoteExecutionResultInput,
|
||||||
): NormalizedRemoteExecutionResult {
|
): NormalizedRemoteExecutionResult {
|
||||||
const rawThreadReply = trimToDefined(input.rawThreadReply);
|
const rawThreadReply = trimToDefined(input.rawThreadReply);
|
||||||
const replyBody = trimToDefined(input.replyBody);
|
const replyBody = trimToDefined(input.replyBody);
|
||||||
const errorMessage = trimToDefined(input.errorMessage);
|
const errorMessage = trimToDefined(input.errorMessage);
|
||||||
|
const warnings = normalizeExecutionWarnings(input.warnings);
|
||||||
const hasEnvironmentDiagnostic =
|
const hasEnvironmentDiagnostic =
|
||||||
looksLikeThreadEnvironmentDiagnostic(rawThreadReply) ||
|
looksLikeThreadEnvironmentDiagnostic(rawThreadReply) ||
|
||||||
looksLikeThreadEnvironmentDiagnostic(replyBody);
|
looksLikeThreadEnvironmentDiagnostic(replyBody);
|
||||||
@@ -74,6 +98,7 @@ export function normalizeRemoteExecutionResult(
|
|||||||
targetThreadId: trimToDefined(input.targetThreadId),
|
targetThreadId: trimToDefined(input.targetThreadId),
|
||||||
errorMessage: errorMessage || buildThreadEnvironmentErrorMessage(),
|
errorMessage: errorMessage || buildThreadEnvironmentErrorMessage(),
|
||||||
requestId: trimToDefined(input.requestId),
|
requestId: trimToDefined(input.requestId),
|
||||||
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +111,7 @@ export function normalizeRemoteExecutionResult(
|
|||||||
replyBody,
|
replyBody,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
requestId: trimToDefined(input.requestId),
|
requestId: trimToDefined(input.requestId),
|
||||||
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface ExecutionImmediateCompletedResult {
|
|||||||
status: "completed";
|
status: "completed";
|
||||||
backendId: string;
|
backendId: string;
|
||||||
output: string;
|
output: string;
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutionImmediateFailedResult {
|
export interface ExecutionImmediateFailedResult {
|
||||||
|
|||||||
@@ -39,6 +39,81 @@ test("ProjectDetailActivity keeps a rendered project snapshot for append-only re
|
|||||||
/private boolean trySkipUnchangedRealtimeMessagesPatch\(JSONObject projectMessagesPayload\)/,
|
/private boolean trySkipUnchangedRealtimeMessagesPatch\(JSONObject projectMessagesPayload\)/,
|
||||||
"expected chat page to expose a duplicate-payload fast path",
|
"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 () => {
|
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\)\);/,
|
/renderLoadedProjectSnapshot\(new ProjectSnapshot\(projectMessagesPayload,\s*null,\s*null\)\);/,
|
||||||
"expected chat page to render the local realtime payload without forcing a network request",
|
"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 applyImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route"))["POST"];
|
||||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
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 = "";
|
let AUTH_SESSION_COOKIE = "";
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
@@ -46,6 +48,8 @@ async function setup() {
|
|||||||
applyImportDraftRoute = applyModule.POST;
|
applyImportDraftRoute = applyModule.POST;
|
||||||
createAuthSession = data.createAuthSession;
|
createAuthSession = data.createAuthSession;
|
||||||
readState = data.readState;
|
readState = data.readState;
|
||||||
|
saveAiAccount = data.saveAiAccount;
|
||||||
|
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
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 () => {
|
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 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(
|
const enrollmentResponse = await createEnrollmentRoute(
|
||||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
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",
|
task.status === "queued",
|
||||||
);
|
);
|
||||||
assert.ok(resolutionTask, "expected import review to leave a queued master-agent task trace");
|
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(
|
const understandingTask = reviewedState.masterAgentTasks.find(
|
||||||
(task) =>
|
(task) =>
|
||||||
task.taskType === "conversation_reply" &&
|
task.taskType === "conversation_reply" &&
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NextRequest } from "next/server";
|
|||||||
|
|
||||||
let runtimeRoot = "";
|
let runtimeRoot = "";
|
||||||
let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
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 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 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"];
|
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_RUNTIME_ROOT = runtimeRoot;
|
||||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
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]/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/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]/confirm/route.ts"),
|
||||||
import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/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;
|
postMessageRoute = messageModule.POST;
|
||||||
|
getProjectRoute = projectModule.GET;
|
||||||
getDispatchPlansRoute = plansModule.GET;
|
getDispatchPlansRoute = plansModule.GET;
|
||||||
confirmDispatchPlanRoute = confirmModule.POST;
|
confirmDispatchPlanRoute = confirmModule.POST;
|
||||||
rejectDispatchPlanRoute = rejectModule.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");
|
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 () => {
|
test("confirming a dispatch plan with rememberLightReminder persists the group reminder preference", async () => {
|
||||||
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
|
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
|
||||||
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
|
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 () => {
|
test("selectExecutionBackendForTesting honors an explicit claw request when claw is enabled", async () => {
|
||||||
const backend = await selectExecutionBackendForTesting({
|
const backend = await selectExecutionBackendForTesting({
|
||||||
primary: { provider: "master_codex_node", status: "ready" },
|
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");
|
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",
|
status: "completed",
|
||||||
backendId: "openai-api",
|
backendId: "openai-api",
|
||||||
output: "done",
|
output: "done",
|
||||||
|
sessionId: "session-completed-1",
|
||||||
};
|
};
|
||||||
const failed: ExecutionImmediateResult = {
|
const failed: ExecutionImmediateResult = {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
|
|||||||
@@ -39,6 +39,40 @@ test("MemoryResolver 在 master-agent 会话下优先挑当前请求命中的项
|
|||||||
assert.equal(resolved.projectMemories[0]?.projectId, "boss-console");
|
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 条", () => {
|
test("MemoryResolver 会保留全局记忆的输入顺序并只截断到 8 条", () => {
|
||||||
const resolved = resolveRelevantMemoriesForTesting({
|
const resolved = resolveRelevantMemoriesForTesting({
|
||||||
projectId: "master-agent",
|
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 个项目记忆", () => {
|
test("Runtime MemoryResolver 在 master-agent 非空请求但无 lexical 命中时回退到前 6 个项目记忆", () => {
|
||||||
const resolved = resolveRuntimeRelevantMemoriesForTesting({
|
const resolved = resolveRuntimeRelevantMemoriesForTesting({
|
||||||
projectId: "master-agent",
|
projectId: "master-agent",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ let runtimeRoot = "";
|
|||||||
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
||||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||||
let createIndependentGroupChat: (typeof import("../src/lib/boss-data"))["createIndependentGroupChat"];
|
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 readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||||
let AUTH_SESSION_COOKIE: string;
|
let AUTH_SESSION_COOKIE: string;
|
||||||
@@ -32,6 +34,8 @@ async function setup() {
|
|||||||
POST = routePost;
|
POST = routePost;
|
||||||
createAuthSession = data.createAuthSession;
|
createAuthSession = data.createAuthSession;
|
||||||
createIndependentGroupChat = data.createIndependentGroupChat;
|
createIndependentGroupChat = data.createIndependentGroupChat;
|
||||||
|
saveAiAccount = data.saveAiAccount;
|
||||||
|
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||||
readState = data.readState;
|
readState = data.readState;
|
||||||
writeState = data.writeState;
|
writeState = data.writeState;
|
||||||
baseState = structuredClone(await readState());
|
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 () => {
|
test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for group text messages", async () => {
|
||||||
await setup();
|
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();
|
const memberProjects = await ensureTwoSingleThreadProjects();
|
||||||
assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects");
|
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,
|
1,
|
||||||
"expected group messages to enqueue a master-agent dispatch recommendation task",
|
"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 () => {
|
test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for single-thread projects", async () => {
|
||||||
|
|||||||
@@ -67,27 +67,49 @@ async function ensureTwoSingleThreadProjects() {
|
|||||||
return singles;
|
return singles;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.ok(singles[0], "expected seeded single-thread project");
|
const generatedProjects = Array.from({ length: 2 - singles.length }, (_, index) => ({
|
||||||
const seed = singles[0];
|
id: `repair-thread-${index + 1}`,
|
||||||
const clone = {
|
name: `Repair Thread ${index + 1}`,
|
||||||
...seed,
|
pinned: false,
|
||||||
id: "repair-thread-clone",
|
systemPinned: false,
|
||||||
name: "Repair Thread Clone",
|
|
||||||
deviceIds: ["mac-studio"],
|
deviceIds: ["mac-studio"],
|
||||||
|
preview: "用于群成员修复 contract 的测试线程。",
|
||||||
|
updatedAt: "2026-03-30T10:00:00+08:00",
|
||||||
|
lastMessageAt: "2026-03-30T10:00:00+08:00",
|
||||||
|
isGroup: false,
|
||||||
threadMeta: {
|
threadMeta: {
|
||||||
...seed.threadMeta,
|
projectId: `repair-thread-${index + 1}`,
|
||||||
projectId: "repair-thread-clone",
|
threadId: `repair-thread-${index + 1}`,
|
||||||
threadId: "repair-thread-clone",
|
threadDisplayName: `维修回归线程 ${index + 1}`,
|
||||||
threadDisplayName: "维修回归线程",
|
|
||||||
folderName: "repair-folder",
|
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",
|
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({
|
await writeState({
|
||||||
...state,
|
...state,
|
||||||
projects: [...state.projects, clone],
|
projects: [...state.projects, ...generatedProjects],
|
||||||
});
|
});
|
||||||
const nextState = await readState();
|
const nextState = await readState();
|
||||||
return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup);
|
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");
|
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 () => {
|
test("conversation reply preflight fails closed when target cwd is missing", async () => {
|
||||||
const missingFolder = "/tmp/boss-local-agent-missing-workdir";
|
const missingFolder = "/tmp/boss-local-agent-missing-workdir";
|
||||||
const stateDbPath = await createCodexStateDb([
|
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 writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||||
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
||||||
let getProjectAgentControls: (typeof import("../src/lib/boss-data"))["getProjectAgentControls"];
|
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 getProjectDetailView: (typeof import("../src/lib/boss-projections"))["getProjectDetailView"];
|
||||||
let getProjectRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
|
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"];
|
let getAgentControlsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/agent-controls/route"))["GET"];
|
||||||
@@ -39,6 +40,7 @@ async function setup() {
|
|||||||
writeState = data.writeState;
|
writeState = data.writeState;
|
||||||
updateProjectAgentControls = data.updateProjectAgentControls;
|
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||||
getProjectAgentControls = data.getProjectAgentControls;
|
getProjectAgentControls = data.getProjectAgentControls;
|
||||||
|
saveAiAccount = data.saveAiAccount;
|
||||||
getProjectDetailView = projections.getProjectDetailView;
|
getProjectDetailView = projections.getProjectDetailView;
|
||||||
getProjectRoute = projectRouteModule.GET;
|
getProjectRoute = projectRouteModule.GET;
|
||||||
getAgentControlsRoute = agentControlsRouteModule.GET;
|
getAgentControlsRoute = agentControlsRouteModule.GET;
|
||||||
@@ -115,20 +117,32 @@ test("master-agent 会话可保存并读取模型与推理强度覆盖", async (
|
|||||||
await updateProjectAgentControls("master-agent", {
|
await updateProjectAgentControls("master-agent", {
|
||||||
modelOverride: "gpt-5.4",
|
modelOverride: "gpt-5.4",
|
||||||
reasoningEffortOverride: "high",
|
reasoningEffortOverride: "high",
|
||||||
|
fastModelOverride: "gpt-5.4-mini",
|
||||||
|
fastReasoningEffortOverride: "low",
|
||||||
|
smartModelOverride: "gpt-5.4",
|
||||||
|
smartReasoningEffortOverride: "high",
|
||||||
});
|
});
|
||||||
|
|
||||||
const controls = await getProjectAgentControls("master-agent");
|
const controls = await getProjectAgentControls("master-agent");
|
||||||
assert.equal(controls?.modelOverride, "gpt-5.4");
|
assert.equal(controls?.modelOverride, "gpt-5.4");
|
||||||
assert.equal(controls?.reasoningEffortOverride, "high");
|
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 state = await readState();
|
||||||
const project = state.projects.find((item) => item.id === "master-agent");
|
const project = state.projects.find((item) => item.id === "master-agent");
|
||||||
assert.equal(project?.agentControls?.modelOverride, "gpt-5.4");
|
assert.equal(project?.agentControls?.modelOverride, "gpt-5.4");
|
||||||
assert.equal(project?.agentControls?.reasoningEffortOverride, "high");
|
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");
|
const detail = getProjectDetailView(state, "master-agent");
|
||||||
assert.equal(detail?.agentControls?.modelOverride, "gpt-5.4");
|
assert.equal(detail?.agentControls?.modelOverride, "gpt-5.4");
|
||||||
assert.equal(detail?.agentControls?.reasoningEffortOverride, "high");
|
assert.equal(detail?.agentControls?.reasoningEffortOverride, "high");
|
||||||
|
assert.equal(detail?.agentControls?.smartModelOverride, "gpt-5.4");
|
||||||
|
assert.equal(detail?.agentControls?.smartReasoningEffortOverride, "high");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("master-agent 对话控制路由可读写并回显到项目详情", async () => {
|
test("master-agent 对话控制路由可读写并回显到项目详情", async () => {
|
||||||
@@ -167,6 +181,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
modelOverride: "gpt-5.4",
|
modelOverride: "gpt-5.4",
|
||||||
reasoningEffortOverride: "medium",
|
reasoningEffortOverride: "medium",
|
||||||
|
fastModelOverride: "gpt-5.4-mini",
|
||||||
|
fastReasoningEffortOverride: "low",
|
||||||
|
smartModelOverride: "gpt-5.4",
|
||||||
|
smartReasoningEffortOverride: "high",
|
||||||
backendOverride: "claw-runtime",
|
backendOverride: "claw-runtime",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -179,6 +197,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
|
|||||||
controls: {
|
controls: {
|
||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
reasoningEffortOverride?: string;
|
reasoningEffortOverride?: string;
|
||||||
|
fastModelOverride?: string;
|
||||||
|
fastReasoningEffortOverride?: string;
|
||||||
|
smartModelOverride?: string;
|
||||||
|
smartReasoningEffortOverride?: string;
|
||||||
backendOverride?: string;
|
backendOverride?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -186,6 +208,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
|
|||||||
assert.equal(postPayload.ok, true);
|
assert.equal(postPayload.ok, true);
|
||||||
assert.equal(postPayload.controls?.modelOverride, "gpt-5.4");
|
assert.equal(postPayload.controls?.modelOverride, "gpt-5.4");
|
||||||
assert.equal(postPayload.controls?.reasoningEffortOverride, "medium");
|
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");
|
assert.equal(postPayload.controls?.backendOverride, "claw-runtime");
|
||||||
|
|
||||||
const getResponse = await getAgentControlsRoute(
|
const getResponse = await getAgentControlsRoute(
|
||||||
@@ -202,6 +228,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
|
|||||||
controls: {
|
controls: {
|
||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
reasoningEffortOverride?: string;
|
reasoningEffortOverride?: string;
|
||||||
|
fastModelOverride?: string;
|
||||||
|
fastReasoningEffortOverride?: string;
|
||||||
|
smartModelOverride?: string;
|
||||||
|
smartReasoningEffortOverride?: string;
|
||||||
backendOverride?: string;
|
backendOverride?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -209,6 +239,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
|
|||||||
assert.equal(getPayload.ok, true);
|
assert.equal(getPayload.ok, true);
|
||||||
assert.equal(getPayload.controls?.modelOverride, "gpt-5.4");
|
assert.equal(getPayload.controls?.modelOverride, "gpt-5.4");
|
||||||
assert.equal(getPayload.controls?.reasoningEffortOverride, "medium");
|
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");
|
assert.equal(getPayload.controls?.backendOverride, "claw-runtime");
|
||||||
|
|
||||||
const projectResponse = await getProjectRoute(
|
const projectResponse = await getProjectRoute(
|
||||||
@@ -225,6 +259,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
|
|||||||
agentControls: {
|
agentControls: {
|
||||||
modelOverride?: string;
|
modelOverride?: string;
|
||||||
reasoningEffortOverride?: string;
|
reasoningEffortOverride?: string;
|
||||||
|
fastModelOverride?: string;
|
||||||
|
fastReasoningEffortOverride?: string;
|
||||||
|
smartModelOverride?: string;
|
||||||
|
smartReasoningEffortOverride?: string;
|
||||||
backendOverride?: string;
|
backendOverride?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -232,6 +270,10 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
|
|||||||
assert.equal(projectPayload.ok, true);
|
assert.equal(projectPayload.ok, true);
|
||||||
assert.equal(projectPayload.agentControls?.modelOverride, "gpt-5.4");
|
assert.equal(projectPayload.agentControls?.modelOverride, "gpt-5.4");
|
||||||
assert.equal(projectPayload.agentControls?.reasoningEffortOverride, "medium");
|
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");
|
assert.equal(projectPayload.agentControls?.backendOverride, "claw-runtime");
|
||||||
} finally {
|
} finally {
|
||||||
if (previousEnv.BOSS_CLAW_ENABLED === undefined) delete process.env.BOSS_CLAW_ENABLED;
|
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 () => {
|
test("master-agent 对话控制按当前账号隔离,不会串到其他用户", async () => {
|
||||||
await setup();
|
await setup();
|
||||||
|
|
||||||
@@ -357,6 +547,78 @@ test("master-agent 对话控制路由单字段更新不会清掉另一字段", a
|
|||||||
assert.equal(payload.controls?.reasoningEffortOverride, "low");
|
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 () => {
|
test("全局接管默认会透传到普通线程会话详情", async () => {
|
||||||
await setup();
|
await setup();
|
||||||
const projectId = await ensureOrdinaryProject("ordinary-takeover-project");
|
const projectId = await ensureOrdinaryProject("ordinary-takeover-project");
|
||||||
|
|||||||
@@ -87,6 +87,62 @@ test("当前对话 override 优先于主控账号默认值", async () => {
|
|||||||
assert.equal(resolved.account.model, "gpt-4.1-mini");
|
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 () => {
|
test("主 Agent 执行配置会合成管理员提示词、用户提示词和当前对话提示词", async () => {
|
||||||
await saveAiAccount({
|
await saveAiAccount({
|
||||||
accountId: "master-codex-primary",
|
accountId: "master-codex-primary",
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { NextRequest } from "next/server";
|
|||||||
let runtimeRoot = "";
|
let runtimeRoot = "";
|
||||||
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
||||||
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
|
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 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 readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||||
let AUTH_SESSION_COOKIE = "";
|
let AUTH_SESSION_COOKIE = "";
|
||||||
@@ -30,7 +32,9 @@ async function setup() {
|
|||||||
|
|
||||||
POST = messageRoute.POST;
|
POST = messageRoute.POST;
|
||||||
saveAiAccount = data.saveAiAccount;
|
saveAiAccount = data.saveAiAccount;
|
||||||
|
getProjectAgentControls = data.getProjectAgentControls;
|
||||||
updateProjectAgentControls = data.updateProjectAgentControls;
|
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||||
|
updateDevice = data.updateDevice;
|
||||||
readState = data.readState;
|
readState = data.readState;
|
||||||
createAuthSession = data.createAuthSession;
|
createAuthSession = data.createAuthSession;
|
||||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||||
@@ -77,6 +81,530 @@ test.beforeEach(async () => {
|
|||||||
await mkdir(runtimeRoot, { recursive: true });
|
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 () => {
|
test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异步实际回复时继承当前会话覆盖", async () => {
|
||||||
await saveAiAccount({
|
await saveAiAccount({
|
||||||
accountId: "openai-master-agent-queue",
|
accountId: "openai-master-agent-queue",
|
||||||
@@ -122,6 +650,7 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
|
|||||||
|
|
||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
message: { id: string };
|
||||||
task?: { taskId: string; taskType: string; status: string } | null;
|
task?: { taskId: string; taskType: string; status: string } | null;
|
||||||
masterReplyState?: "queued" | "running" | "completed";
|
masterReplyState?: "queued" | "running" | "completed";
|
||||||
masterReply?: { accountId?: string } | null;
|
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?.taskType, "conversation_reply");
|
||||||
assert.equal(payload.task?.status, "queued");
|
assert.equal(payload.task?.status, "queued");
|
||||||
assert.ok(payload.task?.taskId, "expected a stable taskId in the response");
|
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 () => {
|
await waitFor(async () => {
|
||||||
const state = await readState();
|
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 () => {
|
test("master-agent enqueue 在首选主节点离线时会回退到可用的备用主节点并返回实际账号", async () => {
|
||||||
await saveAiAccount({
|
await saveAiAccount({
|
||||||
accountId: "master-codex-primary-offline",
|
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(
|
await getUserMasterMemoriesRoute.POST(
|
||||||
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/memories", {
|
new NextRequest("http://127.0.0.1:3000/api/v1/master-agent/memories", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -183,7 +196,7 @@ test("master-agent 记忆页会返回当前用户所有项目记忆", async () =
|
|||||||
assert.equal(payload.ok, true);
|
assert.equal(payload.ok, true);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
payload.memories.project.map((memory) => memory.projectId).sort(),
|
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 当前未启用。",
|
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 createUserMasterMemory: (typeof import("../src/lib/boss-data"))["createUserMasterMemory"];
|
||||||
let updateUserMasterMemory: (typeof import("../src/lib/boss-data"))["updateUserMasterMemory"];
|
let updateUserMasterMemory: (typeof import("../src/lib/boss-data"))["updateUserMasterMemory"];
|
||||||
let archiveUserMasterMemory: (typeof import("../src/lib/boss-data"))["archiveUserMasterMemory"];
|
let archiveUserMasterMemory: (typeof import("../src/lib/boss-data"))["archiveUserMasterMemory"];
|
||||||
|
let stateFile = "";
|
||||||
|
let stateBackupFile = "";
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
if (runtimeRoot) return;
|
if (runtimeRoot) return;
|
||||||
@@ -21,6 +23,8 @@ async function setup() {
|
|||||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-prompts-memory-"));
|
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-prompts-memory-"));
|
||||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
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");
|
const data = await import("../src/lib/boss-data.ts");
|
||||||
readState = data.readState;
|
readState = data.readState;
|
||||||
@@ -34,6 +38,21 @@ async function setup() {
|
|||||||
archiveUserMasterMemory = data.archiveUserMasterMemory;
|
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 () => {
|
test.after(async () => {
|
||||||
if (runtimeRoot) {
|
if (runtimeRoot) {
|
||||||
await rm(runtimeRoot, { recursive: true, force: true });
|
await rm(runtimeRoot, { recursive: true, force: true });
|
||||||
@@ -85,3 +104,27 @@ test("主 Agent 提示词与用户记忆可读写", async () => {
|
|||||||
assert.equal(archived?.archived, true);
|
assert.equal(archived?.archived, true);
|
||||||
assert.equal((await readState()).masterAgentMemories.length, 1);
|
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"/,
|
/"conversation\.updated"/,
|
||||||
"expected project chat page to refresh when conversation.updated is emitted",
|
"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;
|
ok: boolean;
|
||||||
project: { id: string; messages: Array<{ id: string }> };
|
project: { id: string; messages: Array<{ id: string }> };
|
||||||
devices: Array<{ id: string }>;
|
devices: Array<{ id: string }>;
|
||||||
|
conversationTasks: Array<{
|
||||||
|
taskId: string;
|
||||||
|
requestMessageId: string;
|
||||||
|
status: string;
|
||||||
|
sessionId?: string;
|
||||||
|
requestId?: string;
|
||||||
|
}>;
|
||||||
|
executionWarnings: Array<unknown>;
|
||||||
activeThreadContexts?: unknown;
|
activeThreadContexts?: unknown;
|
||||||
recentAppLogs?: unknown;
|
recentAppLogs?: unknown;
|
||||||
openFaults?: 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),
|
payload.devices.map((device) => device.id),
|
||||||
["device-message-lite"],
|
["device-message-lite"],
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(payload.conversationTasks, []);
|
||||||
|
assert.deepEqual(payload.executionWarnings, []);
|
||||||
assert.equal("activeThreadContexts" in payload, false);
|
assert.equal("activeThreadContexts" in payload, false);
|
||||||
assert.equal("recentAppLogs" in payload, false);
|
assert.equal("recentAppLogs" in payload, false);
|
||||||
assert.equal("openFaults" 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 () => {
|
test("GET /api/v1/projects/[projectId]/messages disables caching when unauthorized", async () => {
|
||||||
await setup();
|
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.status, 401);
|
||||||
assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0");
|
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 () => {
|
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]/page.tsx"),
|
||||||
readWorkspaceFile("src/app/conversations/[projectId]/goals/page.tsx"),
|
readWorkspaceFile("src/app/conversations/[projectId]/goals/page.tsx"),
|
||||||
readWorkspaceFile("src/app/conversations/[projectId]/versions/page.tsx"),
|
readWorkspaceFile("src/app/conversations/[projectId]/versions/page.tsx"),
|
||||||
readWorkspaceFile("src/app/conversations/[projectId]/thread-status/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");
|
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],
|
["goals", goalsPage],
|
||||||
["versions", versionsPage],
|
["versions", versionsPage],
|
||||||
["thread-status", threadStatusPage],
|
["thread-status", threadStatusPage],
|
||||||
|
["participants", participantsPage],
|
||||||
] as const) {
|
] as const) {
|
||||||
assert.match(source, /<RealtimeRefresh/, `expected ${label} page to render RealtimeRefresh`);
|
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`);
|
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.equal(normalized.status, "completed");
|
||||||
assert.match(normalized.replyBody ?? "", /gptpluscontrol/);
|
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 assert from "node:assert/strict";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
let runtimeRoot = "";
|
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 createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||||
|
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
||||||
let AUTH_SESSION_COOKIE = "";
|
let AUTH_SESSION_COOKIE = "";
|
||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
@@ -34,6 +35,7 @@ async function setup() {
|
|||||||
createAuthSession = data.createAuthSession;
|
createAuthSession = data.createAuthSession;
|
||||||
readState = data.readState;
|
readState = data.readState;
|
||||||
writeState = data.writeState;
|
writeState = data.writeState;
|
||||||
|
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
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) {
|
async function createAuthedRequest(url: string, method: "POST", body: unknown) {
|
||||||
const session = await createAuthSession({
|
const session = await createAuthSession({
|
||||||
account: "17600003315",
|
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(
|
function findSingleThreadProject(
|
||||||
state: Awaited<ReturnType<typeof readState>>,
|
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) {
|
function buildSingleThreadProject(projectId: string) {
|
||||||
@@ -105,19 +130,19 @@ function buildProjectFolderKey(project: ReturnType<typeof buildSingleThreadProje
|
|||||||
return `${project.deviceIds[0]}:${folderRef}`;
|
return `${project.deviceIds[0]}:${folderRef}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSingleThreadProject() {
|
async function ensureSingleThreadProject(projectId = "single-thread-test") {
|
||||||
const state = await readState();
|
const state = await readState();
|
||||||
const existing = findSingleThreadProject(state);
|
const existing = findSingleThreadProject(state, projectId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const project = buildSingleThreadProject("single-thread-test");
|
const project = buildSingleThreadProject(projectId);
|
||||||
await writeState({
|
await writeState({
|
||||||
...state,
|
...state,
|
||||||
projects: state.projects.concat(project),
|
projects: state.projects.concat(project),
|
||||||
});
|
});
|
||||||
const nextState = await readState();
|
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 () => {
|
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 {
|
const payload = (await response.json()) as {
|
||||||
ok: boolean;
|
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;
|
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.ok(payload.task, "expected single-thread message to return a queued task");
|
||||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||||
assert.equal(payload.task?.status, "queued");
|
assert.equal(payload.task?.status, "queued");
|
||||||
|
assert.equal(payload.task?.requestMessageId, payload.message.id);
|
||||||
|
|
||||||
const nextState = await readState();
|
const nextState = await readState();
|
||||||
const task = nextState.masterAgentTasks.find(
|
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");
|
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 () => {
|
test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when the target device prefers gui mode", async () => {
|
||||||
await setup();
|
await setup();
|
||||||
const singleProject = await ensureSingleThreadProject();
|
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");
|
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