feat: refine mobile master agent sync and chat rendering

This commit is contained in:
kris
2026-04-18 04:51:50 +08:00
parent e0c0ea1814
commit 449f84fcbc
61 changed files with 7051 additions and 1075 deletions

View File

@@ -103,6 +103,7 @@ Android APK
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.11``versionCode=24` - 当前最新 release 构建版本:`2.5.11``versionCode=24`
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一只使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机
- Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect <phone-ip>:5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳 - Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect <phone-ip>:5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳
- Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 WiFi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效 - Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 WiFi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效
- 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试 - 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试

View File

@@ -5,12 +5,14 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".BossApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
android:forceDarkAllowed="false">
<activity <activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"

File diff suppressed because it is too large Load Diff

View File

@@ -147,6 +147,22 @@ 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 updateMasterAgentModeModels(
@Nullable String fastModelOverride,
@Nullable String deepModelOverride,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
payload.put("deepModelOverride", deepModelOverride == null ? JSONObject.NULL : deepModelOverride);
if (modelOverride != null || reasoningEffortOverride != null) {
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
}
return requestWithRestore("POST", "/api/v1/projects/master-agent/agent-controls", payload);
}
public ApiResponse updateProjectTakeoverSettings( public ApiResponse updateProjectTakeoverSettings(
String projectId, String projectId,
@Nullable Boolean takeoverEnabled, @Nullable Boolean takeoverEnabled,
@@ -490,6 +506,10 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject()); return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
} }
public ApiResponse validateDraftAccount(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/accounts/validate-draft", payload);
}
public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException { public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload); return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
} }

View File

@@ -0,0 +1,13 @@
package com.hyzq.boss;
import android.app.Application;
import androidx.appcompat.app.AppCompatDelegate;
public final class BossApplication extends Application {
@Override
public void onCreate() {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
super.onCreate();
}
}

View File

@@ -27,6 +27,7 @@ public final class BossMarkdown {
private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$"); private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$");
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$"); private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$");
private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$"); private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$");
private static final Pattern LABEL_SECTION_PATTERN = Pattern.compile("^([^:\\n]{1,24})[:]\\s*(.+)$");
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");; private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180); private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180);
@@ -86,6 +87,12 @@ public final class BossMarkdown {
continue; continue;
} }
Matcher labelMatcher = LABEL_SECTION_PATTERN.matcher(trimmed);
if (labelMatcher.matches()) {
appendLabelSection(builder, labelMatcher.group(1), labelMatcher.group(2), palette);
continue;
}
if (trimmed.startsWith(">")) { if (trimmed.startsWith(">")) {
appendQuote(builder, trimmed.substring(1).trim(), palette); appendQuote(builder, trimmed.substring(1).trim(), palette);
continue; continue;
@@ -153,6 +160,22 @@ public final class BossMarkdown {
builder.append('\n'); builder.append('\n');
} }
private static void appendLabelSection(
SpannableStringBuilder builder,
String label,
String content,
Palette palette
) {
ensureBlockSeparation(builder, true);
int labelStart = builder.length();
builder.append(label.trim());
builder.setSpan(new StyleSpan(Typeface.BOLD), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new RelativeSizeSpan(1.03f), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
appendInlineStyled(builder, content.trim(), palette);
builder.append('\n');
}
private static void appendCodeBlock(SpannableStringBuilder builder, String text, Palette palette) { private static void appendCodeBlock(SpannableStringBuilder builder, String text, Palette palette) {
if (TextUtils.isEmpty(text)) { if (TextUtils.isEmpty(text)) {
return; return;

View File

@@ -13,6 +13,7 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
@@ -90,6 +91,11 @@ final class BossRealtimeClient {
if (!running) { if (!running) {
return; return;
} }
if (shouldReconnectImmediately(error)) {
Log.i(TAG, "Realtime stream timed out while idle; reconnecting immediately");
backoffMs = INITIAL_BACKOFF_MS;
continue;
}
if (shouldAttemptSessionRestore(error)) { if (shouldAttemptSessionRestore(error)) {
try { try {
BossApiClient.ApiResponse restored = apiClient.restoreSession(); BossApiClient.ApiResponse restored = apiClient.restoreSession();
@@ -174,6 +180,10 @@ final class BossRealtimeClient {
&& apiClient.hasRestoreToken(); && apiClient.hasRestoreToken();
} }
static boolean shouldReconnectImmediately(@Nullable Exception error) {
return error instanceof SocketTimeoutException;
}
private void dispatchEventBlock(String rawBlock) { private void dispatchEventBlock(String rawBlock) {
BossRealtimeEvent event = parseEventBlock(rawBlock); BossRealtimeEvent event = parseEventBlock(rawBlock);
if (event == null || event.eventName.isEmpty()) { if (event == null || event.eventName.isEmpty()) {

View File

@@ -15,6 +15,8 @@ import android.text.TextUtils;
import android.util.TypedValue; import android.util.TypedValue;
import android.view.Gravity; import android.view.Gravity;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.FrameLayout; import android.widget.FrameLayout;
@@ -475,6 +477,10 @@ public final class BossUi {
cell.addView(helperView); cell.addView(helperView);
} }
ViewParent currentParent = field.getParent();
if (currentParent instanceof ViewGroup) {
((ViewGroup) currentParent).removeView(field);
}
field.setPadding(field.getPaddingLeft(), field.getPaddingTop(), field.getPaddingRight(), field.getPaddingBottom()); field.setPadding(field.getPaddingLeft(), field.getPaddingTop(), field.getPaddingRight(), field.getPaddingBottom());
cell.addView(field); cell.addView(field);
return cell; return cell;

View File

@@ -1,5 +1,6 @@
package com.hyzq.boss; package com.hyzq.boss;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
@@ -11,6 +12,7 @@ import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.ViewParent; import android.view.ViewParent;
import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.FrameLayout; import android.widget.FrameLayout;
@@ -141,7 +143,17 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
apiClient = new BossApiClient(this); apiClient = new BossApiClient(this);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); realtimeClient = new BossRealtimeClient(apiClient, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {
handleRealtimeEvent(event);
}
@Override
public void onRealtimeConnectionChanged(boolean connected) {
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
}
});
bindViews(); bindViews();
bindActions(); bindActions();
applyInitialTab(getIntent()); applyInitialTab(getIntent());
@@ -619,11 +631,8 @@ public class MainActivity extends AppCompatActivity {
} }
private void scheduleRealtimeRefresh() { private void scheduleRealtimeRefresh() {
if (realtimeRefreshScheduled) { realtimeRefreshScheduled = false;
return; refreshCurrentTab();
}
realtimeRefreshScheduled = true;
uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
} }
private void cancelRealtimeRefreshSchedule() { private void cancelRealtimeRefreshSchedule() {
@@ -653,7 +662,9 @@ public class MainActivity extends AppCompatActivity {
private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) { private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) {
if ("conversation.context_indicator.updated".equals(event.eventName)) { if ("conversation.context_indicator.updated".equals(event.eventName)) {
return false; return hasProjectId(event)
|| hasDeviceId(event)
|| event.payload.optJSONArray("conversations") != null;
} }
if ("conversation.updated".equals(event.eventName)) { if ("conversation.updated".equals(event.eventName)) {
return hasProjectId(event) || hasDeviceId(event); return hasProjectId(event) || hasDeviceId(event);
@@ -1166,6 +1177,16 @@ public class MainActivity extends AppCompatActivity {
showMessage("缺少 folderKey"); showMessage("缺少 folderKey");
return; return;
} }
if (conversationSearchMode) {
String matchedProjectId = item.optString("searchMatchProjectId", "").trim();
String matchedProjectLabel = item.optString("searchMatchLabel", "").trim();
if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) {
exitConversationSearchMode(true);
openProject(matchedProjectId, matchedProjectLabel);
return;
}
exitConversationSearchMode(true);
}
openConversationFolder( openConversationFolder(
folderKey, folderKey,
resolveConversationFolderName(item, row), resolveConversationFolderName(item, row),
@@ -1180,6 +1201,9 @@ public class MainActivity extends AppCompatActivity {
return; return;
} }
String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle; String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle;
if (conversationSearchMode) {
exitConversationSearchMode(true);
}
openProject(projectId, projectName); openProject(projectId, projectName);
}) })
)); ));
@@ -1292,10 +1316,7 @@ public class MainActivity extends AppCompatActivity {
hideConversationQuickActions(false); hideConversationQuickActions(false);
conversationSearchMode = true; conversationSearchMode = true;
syncTopActionVisualState(screenRefresh.isRefreshing()); syncTopActionVisualState(screenRefresh.isRefreshing());
topSearchInput.post(() -> { showConversationSearchKeyboard();
topSearchInput.requestFocus();
topSearchInput.setSelection(topSearchInput.getText().length());
});
} }
private void exitConversationSearchMode(boolean clearQuery) { private void exitConversationSearchMode(boolean clearQuery) {
@@ -1308,12 +1329,40 @@ public class MainActivity extends AppCompatActivity {
conversationSearchQuery = ""; conversationSearchQuery = "";
topSearchInput.setText(""); topSearchInput.setText("");
} }
hideConversationSearchKeyboard();
syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing()); syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing());
if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) { if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) {
renderConversationsRoot(); renderConversationsRoot();
} }
} }
private void showConversationSearchKeyboard() {
if (topSearchInput == null) {
return;
}
topSearchInput.post(() -> {
topSearchInput.requestFocus();
topSearchInput.setSelection(topSearchInput.getText().length());
InputMethodManager inputMethodManager =
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null) {
inputMethodManager.showSoftInput(topSearchInput, InputMethodManager.SHOW_IMPLICIT);
}
});
}
private void hideConversationSearchKeyboard() {
if (topSearchInput == null) {
return;
}
InputMethodManager inputMethodManager =
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null) {
inputMethodManager.hideSoftInputFromWindow(topSearchInput.getWindowToken(), 0);
}
topSearchInput.clearFocus();
}
private void toggleConversationSelection(String projectId) { private void toggleConversationSelection(String projectId) {
if (selectedConversationProjectIds.contains(projectId)) { if (selectedConversationProjectIds.contains(projectId)) {
selectedConversationProjectIds.remove(projectId); selectedConversationProjectIds.remove(projectId);
@@ -1807,6 +1856,17 @@ public class MainActivity extends AppCompatActivity {
} }
} }
void handleRealtimeConnectionChanged(boolean connected) {
if (!connected
&& shouldMaintainConversationAutoRefresh()
&& !rootTabRefreshInFlight
&& screenRefresh != null
&& !screenRefresh.isRefreshing()) {
refreshCurrentTab();
}
updateConversationAutoRefresh();
}
private void openMeEntry(String key) { private void openMeEntry(String key) {
Intent intent; Intent intent;
switch (key) { switch (key) {

View File

@@ -0,0 +1,121 @@
package com.hyzq.boss;
import android.text.TextUtils;
import androidx.annotation.Nullable;
final class MasterAgentModePresets {
static final class ModePreset {
final String key;
final String label;
@Nullable final String modelOverride;
@Nullable final String reasoningEffortOverride;
ModePreset(
String key,
String label,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride
) {
this.key = key;
this.label = label;
this.modelOverride = modelOverride;
this.reasoningEffortOverride = reasoningEffortOverride;
}
}
static final ModePreset DEFAULT = new ModePreset("default", "沿用默认", null, null);
private static final String DEFAULT_FAST_MODEL = "gpt-5.4-mini";
private static final String DEFAULT_DEEP_MODEL = "gpt-5.4";
private MasterAgentModePresets() {}
static ModePreset[] primaryChoices(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
return new ModePreset[]{
DEFAULT,
new ModePreset("fast", "快速反应", resolveFastModel(fastModelOverride), "low"),
new ModePreset("deep", "深度思考", resolveDeepModel(deepModelOverride), "high")
};
}
static String[] primaryChoiceLabels(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
return new String[]{
"沿用默认",
"快速反应(" + resolveFastModel(fastModelOverride) + "",
"深度思考(" + resolveDeepModel(deepModelOverride) + "",
"更多模型..."
};
}
static int findPrimaryChoiceIndex(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String deepModelOverride
) {
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
if (preset == null) {
return primaryChoiceLabels(fastModelOverride, deepModelOverride).length - 1;
}
ModePreset[] choices = primaryChoices(fastModelOverride, deepModelOverride);
for (int index = 0; index < choices.length; index += 1) {
if (choices[index].key.equals(preset.key)) {
return index;
}
}
return 0;
}
@Nullable
static ModePreset matchPreset(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String deepModelOverride
) {
String model = normalize(modelOverride);
String reasoning = normalize(reasoningEffortOverride);
if (TextUtils.isEmpty(model) && TextUtils.isEmpty(reasoning)) {
return DEFAULT;
}
for (ModePreset preset : primaryChoices(fastModelOverride, deepModelOverride)) {
if (TextUtils.equals(normalize(preset.modelOverride), model)
&& TextUtils.equals(normalize(preset.reasoningEffortOverride), reasoning)) {
return preset;
}
}
return null;
}
static String describeCurrentMode(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String deepModelOverride
) {
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
return preset == null ? "自定义" : preset.label;
}
static String resolveFastModel(@Nullable String fastModelOverride) {
String resolved = normalize(fastModelOverride);
return TextUtils.isEmpty(resolved) ? DEFAULT_FAST_MODEL : resolved;
}
static String resolveDeepModel(@Nullable String deepModelOverride) {
String resolved = normalize(deepModelOverride);
return TextUtils.isEmpty(resolved) ? DEFAULT_DEEP_MODEL : resolved;
}
@Nullable
private static String normalize(@Nullable String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed)) {
return null;
}
return trimmed;
}
}

View File

@@ -39,6 +39,7 @@ import java.util.ArrayList;
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;
import java.util.concurrent.RejectedExecutionException;
public class ProjectDetailActivity extends BossScreenActivity { public class ProjectDetailActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_ID = "project_id";
@@ -55,6 +56,8 @@ 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 currentDeepModelOverride;
private LinearLayout quickActionsLayout; private LinearLayout quickActionsLayout;
private LinearLayout composerRow; private LinearLayout composerRow;
private LinearLayout multiSelectActionsLayout; private LinearLayout multiSelectActionsLayout;
@@ -65,6 +68,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private ScrollView chatScrollView; private ScrollView chatScrollView;
private View pendingOutgoingBubble; private View pendingOutgoingBubble;
private boolean composerSending; private boolean composerSending;
private @Nullable String pendingReplyPresenter;
private boolean renderNearBottom; private boolean renderNearBottom;
private boolean renderForcedScrollToBottom; private boolean renderForcedScrollToBottom;
private boolean conversationInfoReady; private boolean conversationInfoReady;
@@ -102,6 +106,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private boolean reloadInFlight; private boolean reloadInFlight;
private boolean pendingReload; private boolean pendingReload;
private boolean pendingReloadForcedScrollToBottom; private boolean pendingReloadForcedScrollToBottom;
private volatile boolean activityDestroyed;
private final Runnable conversationAutoRefreshRunnable = new Runnable() { private final Runnable conversationAutoRefreshRunnable = new Runnable() {
@Override @Override
public void run() { public void run() {
@@ -124,6 +129,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
triggerRealtimeReload(requireFullSnapshot); triggerRealtimeReload(requireFullSnapshot);
} }
}; };
private final Runnable composerViewportSyncRunnable = new Runnable() {
@Override
public void run() {
syncChatViewportForComposer();
}
};
static final class ChromeBindings { static final class ChromeBindings {
final boolean multiSelecting; final boolean multiSelecting;
@@ -263,10 +274,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
@Override @Override
public void onRealtimeConnectionChanged(boolean connected) { public void onRealtimeConnectionChanged(boolean connected) {
runOnUiThread(() -> { runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
lastKnownRealtimeConnected = connected;
syncRealtimeStatusIndicator();
});
} }
}); });
@@ -297,6 +305,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
@Override @Override
public void afterTextChanged(Editable s) {} public void afterTextChanged(Editable s) {}
}); });
bindComposerViewportSync();
updateComposerSendButtonState(); updateComposerSendButtonState();
updateSelectionUi(); updateSelectionUi();
if (shouldLoadOnCreate()) { if (shouldLoadOnCreate()) {
@@ -306,9 +315,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
activityDestroyed = true;
cancelConversationAutoRefresh(); cancelConversationAutoRefresh();
cancelRealtimeReloadSchedule(); cancelRealtimeReloadSchedule();
stopRealtimeUpdates(); stopRealtimeUpdates();
uiHandler.removeCallbacksAndMessages(null);
replyWaitExecutor.shutdownNow(); replyWaitExecutor.shutdownNow();
super.onDestroy(); super.onDestroy();
} }
@@ -360,7 +371,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (tryApplyRealtimeMessagesPatch(event)) { if (tryApplyRealtimeMessagesPatch(event)) {
return; return;
} }
runOnUiThread(() -> scheduleRealtimeReload(!"project.messages.updated".equals(event.eventName))); runOnUiThread(() -> scheduleRealtimeReload(true));
} }
private boolean tryApplyRealtimeMessagesPatch(BossRealtimeEvent event) { private boolean tryApplyRealtimeMessagesPatch(BossRealtimeEvent event) {
@@ -523,14 +534,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
} }
private void scheduleRealtimeReload(boolean requireFullSnapshot) { private void scheduleRealtimeReload(boolean requireFullSnapshot) {
if (requireFullSnapshot) { realtimeReloadRequiresFullSnapshot = false;
realtimeReloadRequiresFullSnapshot = true; realtimeReloadScheduled = false;
} triggerRealtimeReload(requireFullSnapshot);
if (realtimeReloadScheduled) {
return;
}
realtimeReloadScheduled = true;
uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
} }
private void cancelRealtimeReloadSchedule() { private void cancelRealtimeReloadSchedule() {
@@ -547,6 +553,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
} }
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly) { private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly) {
if (shouldSkipAsyncUiWork()) {
return;
}
if (projectId == null || projectId.isEmpty()) { if (projectId == null || projectId.isEmpty()) {
showMessage("缺少 projectId"); showMessage("缺少 projectId");
finish(); finish();
@@ -561,27 +570,45 @@ public class ProjectDetailActivity extends BossScreenActivity {
renderForcedScrollToBottom = forcedScrollToBottom; renderForcedScrollToBottom = forcedScrollToBottom;
reloadInFlight = true; reloadInFlight = true;
setRefreshing(true); setRefreshing(true);
executor.execute(() -> { try {
try { executor.execute(() -> {
ProjectSnapshot snapshot = messagesOnly try {
? loadProjectMessagesSnapshotForRefresh() ProjectSnapshot snapshot = messagesOnly
: loadProjectSnapshotForRefresh(); ? loadProjectMessagesSnapshotForRefresh()
runOnUiThread(() -> { : loadProjectSnapshotForRefresh();
renderLoadedProjectSnapshot(snapshot); runOnUiThreadIfActive(() -> {
finishReloadCycle(); renderLoadedProjectSnapshot(snapshot);
}); finishReloadCycle();
} catch (Exception error) { });
runOnUiThread(() -> { } catch (Exception error) {
if (messagesOnly) { runOnUiThreadIfActive(() -> {
reloadInFlight = false; if (messagesOnly) {
setRefreshing(false); reloadInFlight = false;
reload(forcedScrollToBottom); setRefreshing(false);
return; reload(forcedScrollToBottom);
} return;
handleProjectReloadFailure(error); }
finishReloadCycle(); handleProjectReloadFailure(error);
}); finishReloadCycle();
});
}
});
} catch (RejectedExecutionException ignored) {
reloadInFlight = false;
setRefreshing(false);
}
}
private boolean shouldSkipAsyncUiWork() {
return activityDestroyed || isFinishing() || isDestroyed();
}
private void runOnUiThreadIfActive(Runnable action) {
runOnUiThread(() -> {
if (shouldSkipAsyncUiWork()) {
return;
} }
action.run();
}); });
} }
@@ -654,6 +681,8 @@ 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));
currentDeepModelOverride = normalizeControlValue(agentControls == null ? null : agentControls.optString("deepModelOverride", null));
if (dispatchPlans != null) { if (dispatchPlans != null) {
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans); currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
currentRejectedDispatchPlan = currentPendingDispatchPlan == null currentRejectedDispatchPlan = currentPendingDispatchPlan == null
@@ -752,6 +781,19 @@ public class ProjectDetailActivity extends BossScreenActivity {
syncRealtimeStatusIndicator(); syncRealtimeStatusIndicator();
} }
void handleRealtimeConnectionChanged(boolean connected) {
lastKnownRealtimeConnected = connected;
syncRealtimeStatusIndicator();
if (!connected
&& shouldMaintainConversationAutoRefresh()
&& !reloadInFlight
&& refreshLayout != null
&& !refreshLayout.isRefreshing()) {
reload();
}
updateConversationAutoRefresh();
}
private boolean shouldMaintainConversationAutoRefresh() { private boolean shouldMaintainConversationAutoRefresh() {
return conversationAutoRefreshEnabled return conversationAutoRefreshEnabled
&& apiClient != null && apiClient != null
@@ -838,6 +880,47 @@ public class ProjectDetailActivity extends BossScreenActivity {
sendProjectMessage("text", body); sendProjectMessage("text", body);
} }
private void bindComposerViewportSync() {
if (composerInput != null) {
composerInput.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
scheduleComposerViewportSync();
}
});
composerInput.setOnClickListener(v -> scheduleComposerViewportSync());
}
if (composerRow != null) {
composerRow.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if ((bottom - top) == (oldBottom - oldTop) && bottom == oldBottom) {
return;
}
if (shouldKeepLatestMessageVisibleForInput()) {
scheduleComposerViewportSync();
}
});
}
}
private void scheduleComposerViewportSync() {
uiHandler.removeCallbacks(composerViewportSyncRunnable);
uiHandler.post(composerViewportSyncRunnable);
uiHandler.postDelayed(composerViewportSyncRunnable, 96L);
}
private void syncChatViewportForComposer() {
if (!shouldKeepLatestMessageVisibleForInput()) {
return;
}
scrollChatToBottom();
}
private boolean shouldKeepLatestMessageVisibleForInput() {
return composerInput != null
&& composerInput.isFocused()
&& composerRow != null
&& composerRow.getVisibility() == View.VISIBLE;
}
private void showAttachmentEntrySheet() { private void showAttachmentEntrySheet() {
if (isComposerBusy()) { if (isComposerBusy()) {
return; return;
@@ -958,6 +1041,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
} }
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan"); JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
JSONObject collaborationGate = response.json.optJSONObject("collaborationGate"); JSONObject collaborationGate = response.json.optJSONObject("collaborationGate");
String replyPresenter = response.json.optString("replyPresenter", "").trim();
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.ReplyWaitSpec waitSpec =
ProjectChatUiState.resolveReplyWaitAfterSend(response.json); ProjectChatUiState.resolveReplyWaitAfterSend(response.json);
runOnUiThread(() -> { runOnUiThread(() -> {
@@ -970,8 +1054,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
} }
currentPendingDispatchPlan = dispatchPlan; currentPendingDispatchPlan = dispatchPlan;
currentRejectedDispatchPlan = null; currentRejectedDispatchPlan = null;
pendingReplyPresenter = replyPresenter.isEmpty() ? null : replyPresenter;
if (dispatchPlan != null) { if (dispatchPlan != null) {
composerSending = false; composerSending = false;
pendingReplyPresenter = null;
updateComposerSendButtonState(); updateComposerSendButtonState();
showMessage( showMessage(
"approval_required".equals(projectCollaborationMode) "approval_required".equals(projectCollaborationMode)
@@ -982,21 +1068,22 @@ public class ProjectDetailActivity extends BossScreenActivity {
return; return;
} }
if (waitSpec.shouldWait) { if (waitSpec.shouldWait) {
if (isMasterAgentConversation()) { startReplyWait(waitSpec, false, buildReplyWaitWaitingMessage());
startMasterAgentReplyWait(waitSpec, false, "消息已发送,主 Agent 思考中");
} else {
startReplyWait(waitSpec, false, "消息已发送,正在等待回复…");
}
return; return;
} }
composerSending = false; composerSending = false;
pendingReplyPresenter = null;
updateComposerSendButtonState(); updateComposerSendButtonState();
if (tryApplyCompletedSendResponse(response.json)) {
return;
}
showMessage("消息已发送"); showMessage("消息已发送");
reload(true); reload(true);
}); });
} catch (Exception error) { } catch (Exception error) {
runOnUiThread(() -> { runOnUiThread(() -> {
composerSending = false; composerSending = false;
pendingReplyPresenter = null;
setRefreshing(false); setRefreshing(false);
removePendingOutgoingBubble(); removePendingOutgoingBubble();
showMessage("发送失败:" + error.getMessage()); showMessage("发送失败:" + error.getMessage());
@@ -1006,6 +1093,51 @@ public class ProjectDetailActivity extends BossScreenActivity {
}); });
} }
private boolean tryApplyCompletedSendResponse(@Nullable JSONObject response) {
if (response == null || currentRenderedProjectPayload == null) {
return false;
}
JSONObject sentMessage = response.optJSONObject("message");
JSONObject replyMessage = response.optJSONObject("replyMessage");
if (sentMessage == null || replyMessage == null) {
return false;
}
JSONObject payload = copyJson(currentRenderedProjectPayload);
JSONObject project = payload.optJSONObject("project");
if (project == null || !TextUtils.equals(project.optString("id", "").trim(), projectId)) {
return false;
}
JSONArray messages = project.optJSONArray("messages");
if (messages == null) {
return false;
}
appendMessageIfMissing(messages, sentMessage);
appendMessageIfMissing(messages, replyMessage);
removePendingOutgoingBubble();
clearMasterAgentReplyState();
renderNearBottom = true;
renderForcedScrollToBottom = true;
renderLoadedProjectSnapshot(new ProjectSnapshot(payload, null, currentParticipantsPayload));
return true;
}
private void appendMessageIfMissing(JSONArray messages, JSONObject message) {
if (messages == null || message == null) {
return;
}
String messageId = message.optString("id", "").trim();
if (messageId.isEmpty()) {
return;
}
for (int index = 0; index < messages.length(); index += 1) {
JSONObject existing = messages.optJSONObject(index);
if (existing != null && TextUtils.equals(existing.optString("id", "").trim(), messageId)) {
return;
}
}
messages.put(copyJson(message));
}
private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) { private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) {
String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli")) String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli"))
? "GUI" ? "GUI"
@@ -1251,10 +1383,45 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!isMasterAgentConversation()) { if (!isMasterAgentConversation()) {
return; return;
} }
final MasterAgentModePresets.ModePreset[] presets = MasterAgentModePresets.primaryChoices(
currentFastModelOverride,
currentDeepModelOverride
);
final String[] options = MasterAgentModePresets.primaryChoiceLabels(
currentFastModelOverride,
currentDeepModelOverride
);
int checkedIndex = MasterAgentModePresets.findPrimaryChoiceIndex(
currentAgentModelOverride,
currentReasoningEffortOverride,
currentFastModelOverride,
currentDeepModelOverride
);
new AlertDialog.Builder(this)
.setTitle("模型")
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
if (which == options.length - 1) {
dialog.dismiss();
showAdvancedMasterAgentModelPicker();
return;
}
MasterAgentModePresets.ModePreset preset = presets[which];
dialog.dismiss();
updateMasterAgentControls(
preset.modelOverride,
preset.reasoningEffortOverride,
"主Agent模式已切换为 " + preset.label
);
})
.setNegativeButton("取消", null)
.show();
}
private void showAdvancedMasterAgentModelPicker() {
final String[] options = buildMasterAgentModelOptions(); final String[] options = buildMasterAgentModelOptions();
int checkedIndex = findCheckedIndex(options, currentAgentModelOverride); int checkedIndex = findCheckedIndex(options, currentAgentModelOverride);
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle("模型") .setTitle("更多模型")
.setSingleChoiceItems(options, checkedIndex, (dialog, which) -> { .setSingleChoiceItems(options, checkedIndex, (dialog, which) -> {
if (which == 0) { if (which == 0) {
dialog.dismiss(); dialog.dismiss();
@@ -1275,7 +1442,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private void showCustomMasterAgentModelDialog() { private void showCustomMasterAgentModelDialog() {
final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false); final EditText input = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4" : currentAgentModelOverride); input.setText(TextUtils.isEmpty(currentAgentModelOverride) ? "gpt-5.4-mini" : currentAgentModelOverride);
new AlertDialog.Builder(this) new AlertDialog.Builder(this)
.setTitle("自定义模型") .setTitle("自定义模型")
.setView(input) .setView(input)
@@ -1351,14 +1518,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (!TextUtils.isEmpty(currentAgentModelOverride)) { if (!TextUtils.isEmpty(currentAgentModelOverride)) {
options.add(currentAgentModelOverride); options.add(currentAgentModelOverride);
} }
if (!options.contains("gpt-5.4")) { String[] preferredModels = new String[]{"gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"};
options.add("gpt-5.4"); for (String model : preferredModels) {
} if (!options.contains(model)) {
if (!options.contains("gpt-5.1")) { options.add(model);
options.add("gpt-5.1"); }
}
if (!options.contains("gpt-4.1")) {
options.add("gpt-4.1");
} }
options.add("自定义..."); options.add("自定义...");
return options.toArray(new String[0]); return options.toArray(new String[0]);
@@ -2134,9 +2298,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
masterAgentReplyWaiting = false; masterAgentReplyWaiting = false;
masterAgentReplyTimedOut = false; masterAgentReplyTimedOut = false;
masterAgentReplyBaselineMessageId = null; masterAgentReplyBaselineMessageId = null;
pendingReplyPresenter = null;
} }
private void scrollChatToBottom() { void scrollChatToBottom() {
if (chatScrollView == null) { if (chatScrollView == null) {
return; return;
} }
@@ -2555,7 +2720,45 @@ public class ProjectDetailActivity extends BossScreenActivity {
} }
protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) { protected void enqueueReplyWaitPoll(@Nullable String baselineMessageId, boolean includeDispatchPlans) {
replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans)); if (shouldSkipAsyncUiWork() || replyWaitExecutor.isShutdown() || replyWaitExecutor.isTerminated()) {
return;
}
try {
replyWaitExecutor.execute(() -> pollUntilReply(baselineMessageId, includeDispatchPlans));
} catch (RejectedExecutionException ignored) {
// Activity is already finishing; ignore late polling requests.
}
}
private String buildReplyWaitWaitingMessage() {
if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) {
return "消息已发送,主 Agent 正在转述";
}
return isMasterAgentConversation()
? "消息已发送,主 Agent 思考中"
: "消息已发送,线程正在处理中";
}
private String buildReplyWaitTimeoutMessage() {
if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) {
return "主 Agent 转述暂未回流,可继续等待或稍后刷新查看。";
}
return isMasterAgentConversation()
? "主 Agent 回复超时,可重试等待最新回复。"
: "当前线程暂未回流,继续后台等待或稍后刷新查看。";
}
private String buildReplyWaitFailureMessage(Exception error) {
String detail = error.getMessage();
if (detail == null || detail.trim().isEmpty()) {
detail = "未知错误";
}
if ("master".equals(pendingReplyPresenter) && !isMasterAgentConversation()) {
return "等待主 Agent 转述失败:" + detail;
}
return isMasterAgentConversation()
? "等待主 Agent 回复失败:" + detail
: "等待线程回复失败:" + detail;
} }
private void pollUntilReply( private void pollUntilReply(
@@ -2565,13 +2768,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS; long deadlineAt = System.currentTimeMillis() + REPLY_WAIT_TIMEOUT_MS;
boolean renderedInitialSnapshot = false; boolean renderedInitialSnapshot = false;
try { try {
while (!Thread.currentThread().isInterrupted() && System.currentTimeMillis() < deadlineAt) { while (!shouldSkipAsyncUiWork()
&& !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 = ProjectChatUiState.hasReplyBeyondBaseline(project, baselineMessageId);
if (!renderedInitialSnapshot || hasReply) { if (!renderedInitialSnapshot || hasReply) {
runOnUiThread(() -> { runOnUiThreadIfActive(() -> {
renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload); renderProject(snapshot.payload, snapshot.dispatchPlans, snapshot.participantsPayload);
if (!hasReply && !isMasterAgentConversation()) { if (!hasReply && !isMasterAgentConversation()) {
composerSending = true; composerSending = true;
@@ -2583,7 +2788,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
} }
if (hasReply) { if (hasReply) {
runOnUiThread(() -> { runOnUiThreadIfActive(() -> {
clearMasterAgentReplyState(); clearMasterAgentReplyState();
composerSending = false; composerSending = false;
updateComposerSendButtonState(); updateComposerSendButtonState();
@@ -2596,7 +2801,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
Thread.sleep(REPLY_WAIT_POLL_INTERVAL_MS); Thread.sleep(REPLY_WAIT_POLL_INTERVAL_MS);
} }
runOnUiThread(() -> { if (shouldSkipAsyncUiWork() || Thread.currentThread().isInterrupted()) {
return;
}
runOnUiThreadIfActive(() -> {
if (isMasterAgentConversation()) { if (isMasterAgentConversation()) {
masterAgentReplyWaiting = false; masterAgentReplyWaiting = false;
masterAgentReplyTimedOut = true; masterAgentReplyTimedOut = true;
@@ -2604,11 +2813,16 @@ public class ProjectDetailActivity extends BossScreenActivity {
composerSending = false; composerSending = false;
updateComposerSendButtonState(); updateComposerSendButtonState();
setRefreshing(false); setRefreshing(false);
showMessage("主 Agent 回复超时,可重试等待最新回复。"); showMessage(buildReplyWaitTimeoutMessage());
reload(false); reload(false);
}); });
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (Exception error) { } catch (Exception error) {
runOnUiThread(() -> { if (shouldSkipAsyncUiWork() || Thread.currentThread().isInterrupted()) {
return;
}
runOnUiThreadIfActive(() -> {
if (isMasterAgentConversation()) { if (isMasterAgentConversation()) {
masterAgentReplyWaiting = false; masterAgentReplyWaiting = false;
masterAgentReplyTimedOut = true; masterAgentReplyTimedOut = true;
@@ -2616,7 +2830,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
composerSending = false; composerSending = false;
updateComposerSendButtonState(); updateComposerSendButtonState();
setRefreshing(false); setRefreshing(false);
showMessage("等待回复失败:" + error.getMessage()); showMessage(buildReplyWaitFailureMessage(error));
reload(false); reload(false);
}); });
} }

View File

@@ -168,6 +168,25 @@ public class ProjectGoalsActivity extends BossScreenActivity {
"" ""
)); ));
JSONObject understanding = project.optJSONObject("projectUnderstanding");
if (understanding != null) {
String projectGoal = understanding.optString("projectGoal").trim();
String currentProgress = understanding.optString("currentProgress").trim();
String recommendedNextStep = understanding.optString("recommendedNextStep").trim();
if (!projectGoal.isEmpty() || !currentProgress.isEmpty() || !recommendedNextStep.isEmpty()) {
StringBuilder summary = new StringBuilder();
appendSummaryLine(summary, "项目目标", projectGoal);
appendSummaryLine(summary, "当前进度", currentProgress);
appendSummaryLine(summary, "建议下一步", recommendedNextStep);
appendContent(BossUi.buildCard(
this,
"同步项目摘要",
summary.toString().trim(),
understanding.optString("updatedAt", "")
));
}
}
if (goals == null || goals.length() == 0) { if (goals == null || goals.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。")); appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。"));
} else { } else {
@@ -187,6 +206,16 @@ public class ProjectGoalsActivity extends BossScreenActivity {
setRefreshing(false); setRefreshing(false);
} }
private void appendSummaryLine(StringBuilder builder, String label, String value) {
if (value == null || value.trim().isEmpty()) {
return;
}
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(label).append("").append(value.trim());
}
private LinearLayout buildGoalChecklistCard(JSONObject goal) { private LinearLayout buildGoalChecklistCard(JSONObject goal) {
LinearLayout card = BossUi.buildCard(this, "", "", ""); LinearLayout card = BossUi.buildCard(this, "", "", "");
card.removeAllViews(); card.removeAllViews();

View File

@@ -13,7 +13,7 @@ import java.util.Map;
public class ProjectVersionsActivity extends BossScreenActivity { public class ProjectVersionsActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name"; public static final String EXTRA_PROJECT_NAME = "project_name";
private static final String GOAL_REFRESH_NOTE = "project_goals.updated"; private static final String VERSION_REFRESH_NOTE = "project_versions.updated";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId; private String projectId;
@@ -24,7 +24,7 @@ public class ProjectVersionsActivity extends BossScreenActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME)); configureScreen("版本记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
setHeaderAction("只读", v -> showMessage("版本记录只读")); setHeaderAction("只读", v -> showMessage("版本记录只读"));
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload(); reload();
@@ -111,7 +111,7 @@ public class ProjectVersionsActivity extends BossScreenActivity {
return false; return false;
} }
String payloadNote = event.payload.optString("note", "").trim(); String payloadNote = event.payload.optString("note", "").trim();
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote); return payloadProjectId.equals(projectId) && VERSION_REFRESH_NOTE.equals(payloadNote);
} }
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {

View File

@@ -85,37 +85,48 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1">
<LinearLayout
<ScrollView
android:id="@+id/project_chat_scroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true" android:orientation="vertical">
android:overScrollMode="ifContentScrolls">
<LinearLayout <LinearLayout
android:id="@+id/project_chat_quick_actions_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical" android:orientation="vertical"
android:paddingLeft="12dp" android:paddingLeft="12dp"
android:paddingTop="10dp" android:paddingTop="10dp"
android:paddingRight="12dp" android:paddingRight="12dp"
android:paddingBottom="20dp"> android:paddingBottom="12dp">
<LinearLayout <LinearLayout
android:id="@+id/project_chat_quick_actions" android:id="@+id/project_chat_quick_actions"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:orientation="horizontal" /> android:orientation="horizontal" />
</LinearLayout>
<ScrollView
android:id="@+id/project_chat_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout <LinearLayout
android:id="@+id/screen_content" android:id="@+id/screen_content"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" /> android:orientation="vertical"
</LinearLayout> android:paddingLeft="12dp"
</ScrollView> android:paddingTop="0dp"
android:paddingRight="12dp"
android:paddingBottom="20dp" />
</ScrollView>
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout <LinearLayout

View File

@@ -37,8 +37,10 @@
<TextView <TextView
android:id="@+id/screen_title" android:id="@+id/screen_title"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="标题" android:text="标题"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="22sp" android:textSize="22sp"
@@ -46,9 +48,11 @@
<TextView <TextView
android:id="@+id/screen_subtitle" android:id="@+id/screen_subtitle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:ellipsize="end"
android:maxLines="1"
android:text="副标题" android:text="副标题"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" /> android:textSize="12sp" />
@@ -95,6 +99,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@color/boss_panel" android:background="@color/boss_panel"
android:orientation="vertical" android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="24dp" /> android:paddingBottom="24dp" />
</ScrollView> </ScrollView>

View File

@@ -9,10 +9,11 @@
<item name="android:windowBackground">@color/boss_bg_app</item> <item name="android:windowBackground">@color/boss_bg_app</item>
</style> </style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:windowBackground">@color/boss_bg_app</item> <item name="android:windowBackground">@color/boss_bg_app</item>
<item name="android:forceDarkAllowed">false</item>
</style> </style>

View File

@@ -1,7 +1,9 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@@ -10,7 +12,6 @@ import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.EditText; import android.widget.EditText;
import android.widget.Spinner; import android.widget.Spinner;
import android.widget.SpinnerAdapter;
import android.widget.TextView; import android.widget.TextView;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
@@ -35,6 +36,7 @@ import java.net.HttpURLConnection;
import java.net.ProtocolException; import java.net.ProtocolException;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -46,78 +48,15 @@ import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(sdk = 34) @Config(sdk = 34)
public class AiAccountsActivityTest { public class AiAccountsActivityTest {
@Test
public void submitOpenAiOnboarding_reportsExplicitPrimaryControllerSuccessAndRefreshesSummary() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
200,
"{\"ok\":true,\"accountId\":\"acc-1\"}",
"{\"ok\":false,\"message\":\"ONBOARD_FAILED\"}"
),
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/acc-1/validate"),
200,
"{\"ok\":true,\"message\":\"校验通过\"}",
"{\"ok\":false,\"message\":\"VALIDATION_FAILED\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
int initialReloadCount = activity.reloadCount;
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号已登录,并设为当前主控。", ShadowToast.getTextOfLatestToast());
assertEquals(initialReloadCount + 1, activity.reloadCount);
}
@Test
public void submitOpenAiOnboarding_showsClearChineseFailurePrefix() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
403,
"{\"ok\":false,\"message\":\"API Key 无效\"}",
"{\"ok\":false,\"message\":\"API Key 无效\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "bad-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号登录失败API Key 无效", ShadowToast.getTextOfLatestToast());
assertEquals(1, activity.reloadCount);
}
@Test @Test
public void activeIdentityCardOffersMainAgentTestEntry() throws Exception { public void activeIdentityCardOffersMainAgentTestEntry() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject activeIdentity = new JSONObject() JSONObject activeIdentity = new JSONObject()
.put("accountId", "acc-1") .put("accountId", "acc-1")
.put("label", " GPT") .put("label", "Agent")
.put("displayName", "OpenAI 平台账号") .put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", " GPT") .put("roleLabel", "链路")
.put("providerLabel", "OpenAI API") .put("providerLabel", "ChatGPT登录")
.put("statusLabel", "ready") .put("statusLabel", "ready")
.put("note", "当前账号可直接生成主 Agent 回复。") .put("note", "当前账号可直接生成主 Agent 回复。")
.put("canGenerate", true); .put("canGenerate", true);
@@ -140,62 +79,547 @@ public class AiAccountsActivityTest {
} }
@Test @Test
public void openAliyunQwenOnboardingDialogUsesPresetModelsWithCustomFallback() throws Exception { public void renderAccountsShowsStructuredSectionsAndExpandedEntries() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject payload = new JSONObject()
.put("activeIdentity", new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主Agent")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("statusLabel", "ready")
.put("canGenerate", true))
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主Agent")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))
.put(new JSONObject()
.put("accountId", "hyzq-backup")
.put("label", "备用API")
.put("displayName", "环宇智擎 备用账号")
.put("roleLabel", "备用链路")
.put("providerLabel", "环宇智擎")
.put("provider", "hyzq_api")
.put("role", "backup")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)
.put("apiKeyConfigured", true)
.put("apiBaseUrl", "https://api.hyzq2046.com/v1"))
.put(new JSONObject()
.put("accountId", "master-node")
.put("label", "主Agent")
.put("displayName", "绑定电脑上的 Codex 节点")
.put("roleLabel", "主链路")
.put("providerLabel", "主Agent 节点")
.put("provider", "master_codex_node")
.put("role", "primary")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
assertNotNull(root);
assertTrue(viewTreeContainsText(root, "主要API配置"));
assertTrue(viewTreeContainsText(root, "备用API配置"));
assertFalse(viewTreeContainsText(root, "OAuth 登录"));
assertFalse(viewTreeContainsText(root, "API 接入"));
assertFalse(viewTreeContainsText(root, "谷歌登录"));
assertFalse(viewTreeContainsText(root, "ChatGPT登录"));
assertFalse(viewTreeContainsText(root, "阿里"));
assertFalse(viewTreeContainsText(root, "Minimax"));
assertFalse(viewTreeContainsText(root, "GLM"));
assertFalse(viewTreeContainsText(root, "环宇智擎"));
assertFalse(viewTreeContainsText(root, "自定义"));
assertFalse(viewTreeContainsText(root, "绑定设备节点"));
}
@Test
public void tappingPrimaryConfigEntryOpensPrimaryDetailPage() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "openAliyunQwenOnboardingDialog"); ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "主要API配置");
assertNotNull(entry);
entry.performClick();
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
Intent nextIntent = shadowActivity.getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(AiAccountsActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("primary", nextIntent.getStringExtra("ai_accounts_role"));
}
@Test
public void detailPageShowsOnlySelectedRoleConfiguration() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
JSONObject payload = new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))
.put(new JSONObject()
.put("accountId", "hyzq-primary")
.put("label", "主要API")
.put("displayName", "环宇智擎 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "环宇智擎")
.put("provider", "hyzq_api")
.put("role", "primary")
.put("model", "gpt-5.4")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)
.put("apiKeyConfigured", true)
.put("apiBaseUrl", "https://api.hyzq2046.com/v1")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
assertNotNull(root);
assertTrue(viewTreeContainsText(root, "当前使用方式"));
assertTrue(viewTreeContainsText(root, "主Agent模式"));
assertTrue(viewTreeContainsText(root, "快速反应模型"));
assertTrue(viewTreeContainsText(root, "深度思考模型"));
assertTrue(viewTreeContainsText(root, "ChatGPT登录"));
assertTrue(viewTreeContainsText(root, "OAuth 登录"));
assertTrue(viewTreeContainsText(root, "当前模型gpt-5.4-mini"));
assertTrue(viewTreeContainsText(root, "当前:沿用默认"));
assertTrue(viewTreeContainsText(root, "当前gpt-5.4-mini"));
assertTrue(viewTreeContainsText(root, "当前gpt-5.4"));
assertTrue(viewTreeContainsText(root, "API 接入"));
assertTrue(viewTreeContainsText(root, "已配置ChatGPT登录"));
assertTrue(viewTreeContainsText(root, "已配置:环宇智擎"));
assertFalse(viewTreeContainsText(root, "谷歌登录"));
assertFalse(viewTreeContainsText(root, "阿里"));
assertFalse(viewTreeContainsText(root, "Minimax"));
assertFalse(viewTreeContainsText(root, "GLM"));
assertFalse(viewTreeContainsText(root, "自定义"));
assertFalse(viewTreeContainsText(root, "可编辑配置"));
assertFalse(viewTreeContainsText(root, "当前已保存"));
assertFalse(viewTreeContainsText(root, "只读状态"));
assertFalse(viewTreeContainsText(root, "备用API配置"));
}
@Test
public void currentMethodEntryOpensCurrentAccountEditor() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.setField(activity, "currentMasterAgentModelOverride", "gpt-5.4-mini");
ReflectionHelpers.setField(activity, "currentMasterAgentReasoningEffortOverride", "low");
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-5.4-mini");
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
JSONObject payload = new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true)));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "当前使用方式");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle(); Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog); assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
View root = dialog.getWindow().getDecorView(); assertTrue(viewTreeContainsText(dialogRoot, "账号快捷登录"));
Spinner modelSpinner = findSpinnerContainingItem(root, "qwen3.5-plus"); assertTrue(viewTreeContainsText(dialogRoot, "选择模型"));
assertNotNull(modelSpinner);
SpinnerAdapter adapter = modelSpinner.getAdapter();
assertNotNull(adapter);
assertEquals(3, adapter.getCount());
assertEquals("qwen3.5-plus", adapter.getItem(0).toString());
assertEquals("qwen3.5-flash", adapter.getItem(1).toString());
assertEquals("自定义模型", adapter.getItem(2).toString());
assertEquals("qwen3.5-plus", modelSpinner.getSelectedItem().toString());
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
assertNotNull(customModelInput);
} }
@Test @Test
public void openAccountEditorShowsCustomFallbackForNonPresetAliyunModel() throws Exception { public void fastModeEntryOpensDedicatedModelPicker() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); Intent intent = new Intent(
JSONObject existing = new JSONObject() org.robolectric.RuntimeEnvironment.getApplication(),
.put("accountId", "acc-1") TestAiAccountsActivity.class
.put("label", "备用 GPT") );
.put("displayName", "阿里百炼备用账号") intent.putExtra("ai_accounts_role", "primary");
.put("provider", "aliyun_qwen_api") TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
.put("model", "qwen-custom-x"); ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-4.1");
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
ReflectionHelpers.callInstanceMethod( ReflectionHelpers.callInstanceMethod(
activity, activity,
"openAccountEditor", "renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing), ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "快速反应模型");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "快速反应模型"));
assertTrue(viewTreeContainsText(dialogRoot, "gpt-4.1"));
}
@Test
public void tappingOauthEntryShowsOauthProviderChooser() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "OAuth 登录");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "谷歌登录"));
assertTrue(viewTreeContainsText(dialogRoot, "ChatGPT登录"));
}
@Test
public void tappingApiEntryShowsApiProviderChooser() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "API 接入");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "阿里"));
assertTrue(viewTreeContainsText(dialogRoot, "Minimax"));
assertTrue(viewTreeContainsText(dialogRoot, "GLM"));
assertTrue(viewTreeContainsText(dialogRoot, "环宇智擎"));
assertTrue(viewTreeContainsText(dialogRoot, "自定义"));
}
@Test
public void defaultApiBaseUrlForProviderSupportsExpandedApiProviders() {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
String openai = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "openai_api")
);
String aliyun = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "aliyun_qwen_api")
);
String minimax = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "minimax_api")
);
String glm = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "glm_api")
);
String hyzq = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
);
String custom = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "custom_api")
);
assertEquals("https://api.openai.com/v1", openai);
assertEquals("https://dashscope.aliyuncs.com/compatible-mode/v1", aliyun);
assertEquals("https://api.minimaxi.com/v1", minimax);
assertEquals("https://open.bigmodel.cn/api/paas/v4", glm);
assertEquals("https://api.hyzq2046.com/v1", hyzq);
assertEquals("", custom);
}
@Test
public void openOauthAccountDialogShowsLoginAction() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openOauthAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "google_oauth"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(root, "账号快捷登录"));
assertTrue(viewTreeContainsText(root, "谷歌登录"));
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner);
assertFalse(modelSpinner.isEnabled());
assertFalse(modelSpinner.isClickable());
}
@Test
public void openOauthAccountDialogEnablesModelSelectionWhenAccountIsReady() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject existing = new JSONObject()
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("accountIdentifier", "kris@example.com")
.put("model", "gpt-5.4")
.put("loginStatusNote", "已登录")
.put("enabled", true)
.put("isActive", true)
.put("status", "ready")
.put("statusLabel", "ready");
ReflectionHelpers.callInstanceMethod(
activity,
"openOauthAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner);
assertTrue(modelSpinner.isEnabled());
assertTrue(modelSpinner.isClickable());
}
@Test
public void openApiAccountDialogLocksModelSelectionBeforeValidation() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openApiAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, null) ReflectionHelpers.ClassParameter.from(String.class, null)
); );
Shadows.shadowOf(Looper.getMainLooper()).idle(); Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog); assertNotNull(dialog);
View root = dialog.getWindow().getDecorView(); View root = dialog.getWindow().getDecorView();
Spinner modelSpinner = findSpinnerContainingItem(root, "自定义模型"); assertNotNull(findEditTextWithHint(root, "账号标识 / 备注"));
assertNotNull(findEditTextWithHint(root, "API Key"));
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner); assertNotNull(modelSpinner);
SpinnerAdapter adapter = modelSpinner.getAdapter(); assertFalse(modelSpinner.isEnabled());
assertNotNull(adapter); assertEquals(0, ((android.widget.ArrayAdapter<?>) modelSpinner.getAdapter()).getCount());
assertEquals(3, adapter.getCount()); }
assertEquals("自定义模型", modelSpinner.getSelectedItem().toString());
EditText customModelInput = findEditTextWithHint(root, "自定义模型"); @Test
assertNotNull(customModelInput); public void applyDraftValidatedModelsEnablesModelSelection() throws Exception {
assertEquals("qwen-custom-x", customModelInput.getText().toString()); TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
Spinner spinner = new Spinner(activity);
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
activity,
android.R.layout.simple_spinner_dropdown_item,
new ArrayList<>()
);
spinner.setAdapter(adapter);
spinner.setEnabled(false);
org.json.JSONArray models = new org.json.JSONArray().put("gpt-5.4-mini").put("gpt-5.4");
ReflectionHelpers.callInstanceMethod(
activity,
"applyValidatedApiModels",
ReflectionHelpers.ClassParameter.from(Spinner.class, spinner),
ReflectionHelpers.ClassParameter.from(android.widget.ArrayAdapter.class, adapter),
ReflectionHelpers.ClassParameter.from(org.json.JSONArray.class, models),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4")
);
assertTrue(spinner.isEnabled());
assertEquals(2, adapter.getCount());
assertEquals("gpt-5.4", spinner.getSelectedItem());
}
@Test
public void saveExpandedApiProviderUsesGenericCreateFlowAndAutoFillsBaseUrl() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
RecordingConnection createConnection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts"),
200,
"{\"ok\":true,\"accountId\":\"acc-1\"}",
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
);
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
int initialReloadCount = activity.reloadCount;
ReflectionHelpers.callInstanceMethod(
activity,
"saveAccount",
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, "备用API"),
ReflectionHelpers.ClassParameter.from(String.class, "环宇智擎备用账号"),
ReflectionHelpers.ClassParameter.from(String.class, "fallback@example.com"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq-secret"),
ReflectionHelpers.ClassParameter.from(String.class, "待校验"),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
assertEquals(initialReloadCount + 1, activity.reloadCount);
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
assertEquals("hyzq_api", requestJson.getString("provider"));
assertEquals("backup", requestJson.getString("role"));
assertEquals("https://api.hyzq2046.com/v1", requestJson.getString("apiBaseUrl"));
assertEquals("hyzq-secret", requestJson.getString("apiKey"));
}
@Test
public void saveOauthAccountUsesGenericCreateFlow() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
RecordingConnection createConnection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts"),
200,
"{\"ok\":true,\"accountId\":\"acc-2\"}",
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
);
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"saveAccount",
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, "主Agent"),
ReflectionHelpers.ClassParameter.from(String.class, "ChatGPT OAuth 主链路账号"),
ReflectionHelpers.ClassParameter.from(String.class, "kris@example.com"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "待网页登录"),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
assertEquals("chatgpt_oauth", requestJson.getString("provider"));
assertEquals("primary", requestJson.getString("role"));
assertEquals("待网页登录", requestJson.getString("loginStatusNote"));
assertEquals("", requestJson.getString("apiBaseUrl"));
} }
private static final class TestAiAccountsActivity extends AiAccountsActivity { private static final class TestAiAccountsActivity extends AiAccountsActivity {
@@ -279,8 +703,6 @@ public class AiAccountsActivityTest {
private final int responseCodeValue; private final int responseCodeValue;
private final String responseBody; private final String responseBody;
private final String errorBody; private final String errorBody;
private String requestMethodValue = "GET";
private String contentTypeValue = "";
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) { RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
super(url); super(url);
@@ -301,16 +723,11 @@ public class AiAccountsActivityTest {
public void connect() {} public void connect() {}
@Override @Override
public void setRequestMethod(String method) throws ProtocolException { public void setRequestMethod(String method) throws ProtocolException {}
requestMethodValue = method;
}
@Override @Override
public void setRequestProperty(String key, String value) { public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value); requestHeaders.put(key, value);
if ("Content-Type".equalsIgnoreCase(key)) {
contentTypeValue = value;
}
} }
@Override @Override
@@ -337,6 +754,10 @@ public class AiAccountsActivityTest {
public Map<String, List<String>> getHeaderFields() { public Map<String, List<String>> getHeaderFields() {
return Collections.emptyMap(); return Collections.emptyMap();
} }
String getCapturedRequestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
} }
private static final class InMemorySharedPreferences implements SharedPreferences { private static final class InMemorySharedPreferences implements SharedPreferences {
@@ -484,32 +905,6 @@ public class AiAccountsActivityTest {
return false; return false;
} }
private static Spinner findSpinnerContainingItem(View root, String expectedText) {
if (root instanceof Spinner) {
Spinner spinner = (Spinner) root;
SpinnerAdapter adapter = spinner.getAdapter();
if (adapter != null) {
for (int index = 0; index < adapter.getCount(); index += 1) {
Object item = adapter.getItem(index);
if (item != null && item.toString().contains(expectedText)) {
return spinner;
}
}
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
Spinner match = findSpinnerContainingItem(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static EditText findEditTextWithHint(View root, String expectedText) { private static EditText findEditTextWithHint(View root, String expectedText) {
if (root instanceof EditText) { if (root instanceof EditText) {
CharSequence hint = ((EditText) root).getHint(); CharSequence hint = ((EditText) root).getHint();
@@ -529,4 +924,41 @@ public class AiAccountsActivityTest {
} }
return null; return null;
} }
private static EditText findEditTextWithText(View root, String expectedText) {
if (root instanceof EditText) {
CharSequence text = ((EditText) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return (EditText) root;
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
EditText match = findEditTextWithText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static Spinner findSpinner(View root) {
if (root instanceof Spinner) {
return (Spinner) root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
Spinner match = findSpinner(group.getChildAt(index));
if (match != null) {
return match;
}
}
return null;
}
} }

View File

@@ -170,6 +170,27 @@ public class BossApiClientDispatchPlansTest {
); );
} }
@Test
public void updateMasterAgentModeModelsWritesFastAndDeepModelMappings() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.updateMasterAgentModeModels(
"gpt-4.1",
"gpt-5.1",
"gpt-4.1",
"low"
);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"fastModelOverride\":\"gpt-4.1\",\"deepModelOverride\":\"gpt-5.1\",\"modelOverride\":\"gpt-4.1\",\"reasoningEffortOverride\":\"low\"}",
connection.requestBody()
);
}
@Test @Test
public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception { public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile")); RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));

View File

@@ -60,4 +60,32 @@ public class BossMarkdownTest {
assertEquals("(空消息)", rendered.toString()); assertEquals("(空消息)", rendered.toString());
} }
@Test
public void render_normalizesColonSectionsIntoReadableBlocks() {
Context context = RuntimeEnvironment.getApplication();
CharSequence rendered = BossMarkdown.render(
context,
"项目目标:完成 Boss 真机回归\n" +
"当前进度:已完成 UI 调整\n" +
"下一步:推送到 Gitea",
false
);
assertTrue(rendered instanceof Spanned);
Spanned spanned = (Spanned) rendered;
String text = spanned.toString();
assertTrue(text.contains("项目目标"));
assertTrue(text.contains("完成 Boss 真机回归"));
assertTrue(text.indexOf("项目目标") < text.indexOf("完成 Boss 真机回归"));
assertTrue(text.contains("当前进度"));
assertTrue(text.contains("已完成 UI 调整"));
assertTrue(text.indexOf("当前进度") < text.indexOf("已完成 UI 调整"));
assertTrue(text.contains("下一步"));
assertTrue(text.contains("推送到 Gitea"));
assertTrue(text.indexOf("下一步") < text.indexOf("推送到 Gitea"));
assertTrue(text.contains("\n"));
}
} }

View File

@@ -1,13 +1,18 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import java.net.SocketTimeoutException;
import java.io.IOException;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(sdk = 34) @Config(sdk = 34)
public class BossRealtimeClientTest { public class BossRealtimeClientTest {
@@ -37,4 +42,10 @@ public class BossRealtimeClientTest {
public void parseEventBlockReturnsNullForEmptyEventPayloads() { public void parseEventBlockReturnsNullForEmptyEventPayloads() {
assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n")); assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n"));
} }
@Test
public void socketTimeoutReconnectsImmediately() {
assertTrue(BossRealtimeClient.shouldReconnectImmediately(new SocketTimeoutException("timeout")));
assertFalse(BossRealtimeClient.shouldReconnectImmediately(new IOException("boom")));
}
} }

View File

@@ -0,0 +1,28 @@
package com.hyzq.boss;
import static org.junit.Assert.assertSame;
import android.content.Context;
import android.widget.EditText;
import android.widget.LinearLayout;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossUiFormCellTest {
@Test
public void buildFormCell_detachesFieldFromPreviousParentBeforeReusingIt() {
Context context = RuntimeEnvironment.getApplication();
EditText field = new EditText(context);
BossUi.buildFormCell(context, "模型", "第一次渲染", field);
LinearLayout secondCell = BossUi.buildFormCell(context, "模型", "刷新后重建", field);
assertSame(secondCell, field.getParent());
}
}

View File

@@ -5,8 +5,10 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.view.View; import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.LinearLayout; import android.widget.LinearLayout;
@@ -21,6 +23,8 @@ import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.robolectric.Shadows; import org.robolectric.Shadows;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.ShadowInputMethodManager;
import org.robolectric.util.ReflectionHelpers; import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@@ -124,7 +128,58 @@ public class MainActivityConversationSearchTest {
} }
@Test @Test
public void searchHitInsideArchivedProject_keepsProjectContextAndOpensFolderPage() throws Exception { public void searchMode_showsSoftKeyboardWhenActivated() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton searchButton = activity.findViewById(R.id.search_button);
searchButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInput = activity.findViewById(R.id.top_search_input);
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
assertTrue(searchInput.isFocused());
assertTrue(shadowInputMethodManager.isSoftInputVisible());
}
@Test
public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton searchButton = activity.findViewById(R.id.search_button);
searchButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInput = activity.findViewById(R.id.top_search_input);
searchInput.setText("树莓派");
Shadows.shadowOf(activity.getMainLooper()).idle();
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View row = getRecyclerChild(list, 0);
row.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("p1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
assertEquals("", searchInput.getText().toString());
assertFalse(shadowInputMethodManager.isSoftInputVisible());
assertFalse(activity.isFinishing());
}
@Test
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray() ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject() .put(new JSONObject()
@@ -155,14 +210,50 @@ public class MainActivityConversationSearchTest {
row.performClick(); row.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("thread-revert-1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
assertEquals("发布回滚", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME));
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
assertEquals("", searchInput.getText().toString());
assertFalse(activity.isFinishing());
}
@Test
public void archivedProjectSearchByFolderName_stillOpensFolderPage() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()
.put("projectId", "folder-boss")
.put("conversationType", "folder_archive")
.put("folderKey", "mac-studio:boss")
.put("folderLabel", "Boss")
.put("projectTitle", "Boss")
.put("threadTitle", "Boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyLabel", "11:00")
.put("searchAliases", new JSONArray().put("发布回滚"))
.put("searchTargetProjectIds", new JSONArray().put("thread-revert-1"))));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSearchMode");
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInput = activity.findViewById(R.id.top_search_input);
searchInput.setText("Boss");
Shadows.shadowOf(activity.getMainLooper()).idle();
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View row = getRecyclerChild(list, 0);
row.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity(); Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName()); assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY)); assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY));
assertEquals("Boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME)); assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
assertEquals("thread-revert-1", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID)); assertEquals("", searchInput.getText().toString());
assertEquals(2, nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS).length);
assertEquals("thread-revert-2", nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS)[1]);
assertEquals("发布回滚", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL));
} }
private static JSONArray buildConversations() throws Exception { private static JSONArray buildConversations() throws Exception {

View File

@@ -260,6 +260,27 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.meRefreshCount); assertEquals(0, activity.meRefreshCount);
} }
@Test
public void realtimeDisconnectTriggersImmediateConversationFallbackRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
}
@Test @Test
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception { public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();

View File

@@ -1,6 +1,7 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -51,6 +52,66 @@ public class ProjectDetailActivityMasterAgentMenuTest {
assertMenuItem(listView, 6, "刷新"); assertMenuItem(listView, 6, "刷新");
} }
@Test
public void masterAgentModelOptionsIncludeFastAndDeepChoices() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
assertArrayEquals(
new String[]{"沿用默认", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
options
);
}
@Test
public void masterAgentModelOptionsKeepCurrentCustomChoice() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "currentAgentModelOverride", "gpt-4.1-mini");
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
assertArrayEquals(
new String[]{"沿用默认", "gpt-4.1-mini", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
options
);
}
@Test
public void masterAgentModelPickerShowsFastAndDeepModes() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentModelPicker");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "沿用默认");
assertMenuItem(listView, 1, "快速反应gpt-5.4-mini");
assertMenuItem(listView, 2, "深度思考gpt-5.4");
assertMenuItem(listView, 3, "更多模型...");
}
@Test @Test
public void normalConversationMoreMenuShowsInfoAndRefresh() { public void normalConversationMoreMenuShowsInfoAndRefresh() {
Intent intent = new Intent() Intent intent = new Intent()

View File

@@ -251,6 +251,54 @@ public class ProjectDetailActivityRealtimeTest {
assertEquals(2, activity.renderCount); assertEquals(2, activity.renderCount);
} }
@Test
public void realtimeDisconnectTriggersImmediateConversationFallbackReload() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
activity.getSharedPreferences("boss_native_client", android.content.Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void reloadSnapshotAfterDestroyDoesNotCrashWhenExecutorsAreShutdown() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.pause()
.destroy()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"reloadSnapshot",
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
assertEquals(0, activity.loadCallCount);
}
private static void waitFor(BooleanSupplier condition) throws Exception { private static void waitFor(BooleanSupplier condition) throws Exception {
long deadlineAt = System.currentTimeMillis() + 2_000L; long deadlineAt = System.currentTimeMillis() + 2_000L;
while (System.currentTimeMillis() < deadlineAt) { while (System.currentTimeMillis() < deadlineAt) {

View File

@@ -2,8 +2,9 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@@ -14,6 +15,7 @@ import android.widget.Button;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ListView; import android.widget.ListView;
import android.widget.ScrollView;
import android.widget.TextView; import android.widget.TextView;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray; import org.json.JSONArray;
@@ -23,6 +25,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric; import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowDialog; import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers; import org.robolectric.util.ReflectionHelpers;
@@ -30,6 +33,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(sdk = 34) @Config(sdk = 34)
@@ -87,6 +91,91 @@ public class ProjectDetailActivityUiTest {
assertEquals(View.GONE, refreshButton.getVisibility()); assertEquals(View.GONE, refreshButton.getVisibility());
} }
@Test
public void composerFocus_scrollsChatToBottomToKeepLatestMessageVisible() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
View composerInput = activity.findViewById(R.id.project_chat_input);
composerInput.requestFocus();
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(composerInput.isFocused());
assertTrue(activity.scrollChatToBottomCount > 0);
}
@Test
public void quickActionsStayOutsideScrollableMessageContainer() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
LinearLayout quickActions = activity.findViewById(R.id.project_chat_quick_actions);
ScrollView chatScrollView = activity.findViewById(R.id.project_chat_scroll);
LinearLayout contentLayout = activity.findViewById(R.id.screen_content);
assertNotNull(quickActions);
assertNotNull(chatScrollView);
assertNotNull(contentLayout);
assertEquals(R.id.project_chat_quick_actions_container, ((View) quickActions.getParent()).getId());
assertEquals(View.NO_ID, ((View) chatScrollView.getParent()).getId());
assertEquals(R.id.project_chat_scroll, ((View) contentLayout.getParent()).getId());
}
@Test
public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
View composerInput = activity.findViewById(R.id.project_chat_input);
View composerRow = activity.findViewById(R.id.project_chat_composer_row);
composerRow.layout(0, 0, 1080, 120);
composerInput.requestFocus();
Shadows.shadowOf(activity.getMainLooper()).idle();
int baselineScrollCount = activity.scrollChatToBottomCount;
composerRow.layout(0, 0, 1080, 220);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(activity.scrollChatToBottomCount > baselineScrollCount);
}
@Test
public void composerRowLayoutChangeWithoutFocusedInput_doesNotScrollChatToBottom() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
View composerRow = activity.findViewById(R.id.project_chat_composer_row);
composerRow.layout(0, 0, 1080, 120);
Shadows.shadowOf(activity.getMainLooper()).idle();
int baselineScrollCount = activity.scrollChatToBottomCount;
composerRow.layout(0, 0, 1080, 220);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(baselineScrollCount, activity.scrollChatToBottomCount);
}
@Test @Test
public void manualAnalysisAttachmentShowsActionChip() throws Exception { public void manualAnalysisAttachmentShowsActionChip() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
@@ -233,6 +322,77 @@ public class ProjectDetailActivityUiTest {
assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26")); assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26"));
} }
@Test
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject initialPayload = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
JSONObject userMessage = new JSONObject()
.put("id", "msg-user-fast")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "你现在是什么模型")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:00:00.000Z");
JSONObject replyMessage = new JSONObject()
.put("id", "msg-master-fast")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "当前主 Agent 是 gpt-5.4-mini。")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:00:01.000Z");
JSONObject sendResponse = new JSONObject()
.put("ok", true)
.put("message", userMessage)
.put("replyMessage", replyMessage)
.put("masterReplyState", "completed")
.put("replyPresenter", "master")
.put("task", JSONObject.NULL)
.put("dispatchPlan", JSONObject.NULL)
.put("collaborationGate", new JSONObject()
.put("isGroup", false)
.put("collaborationMode", "development")
.put("approvalState", "not_required"));
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"sendProjectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "text"),
ReflectionHelpers.ClassParameter.from(String.class, "你现在是什么模型")
);
waitForUiCondition(
activity,
() -> viewTreeContainsText(activity.findViewById(R.id.screen_content), "当前主 Agent 是 gpt-5.4-mini。")
|| fakeApiClient.projectDetailCallCount > 0
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "你现在是什么模型"));
assertTrue(viewTreeContainsText(content, "当前主 Agent 是 gpt-5.4-mini。"));
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test @Test
public void masterAgentHeaderUsesWechatMoreMenuLabel() { public void masterAgentHeaderUsesWechatMoreMenuLabel() {
Intent intent = new Intent() Intent intent = new Intent()
@@ -275,6 +435,21 @@ public class ProjectDetailActivityUiTest {
assertEquals("更多", String.valueOf(headerAction.getContentDescription())); assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
} }
@Test
public void normalConversationUsesThreadSpecificReplyWaitTimeoutCopy() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
String timeoutMessage = ReflectionHelpers.callInstanceMethod(activity, "buildReplyWaitTimeoutMessage");
assertEquals("当前线程暂未回流,继续后台等待或稍后刷新查看。", timeoutMessage);
}
@Test @Test
public void renderProjectKeepsMasterAgentWaitingStateVisibleInMessageFlow() throws Exception { public void renderProjectKeepsMasterAgentWaitingStateVisibleInMessageFlow() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
@@ -703,10 +878,23 @@ public class ProjectDetailActivityUiTest {
return null; return null;
} }
private static void waitForUiCondition(TestProjectDetailActivity activity, BooleanSupplier condition) throws Exception {
long deadline = System.currentTimeMillis() + 2_000L;
while (System.currentTimeMillis() < deadline) {
Shadows.shadowOf(activity.getMainLooper()).idle();
if (condition.getAsBoolean()) {
return;
}
Thread.sleep(10L);
}
fail("condition was not met before timeout");
}
public static class TestProjectDetailActivity extends ProjectDetailActivity { public static class TestProjectDetailActivity extends ProjectDetailActivity {
int replyWaitPollCount; int replyWaitPollCount;
String lastReplyWaitBaselineMessageId; String lastReplyWaitBaselineMessageId;
boolean lastReplyWaitIncludeDispatchPlans; boolean lastReplyWaitIncludeDispatchPlans;
int scrollChatToBottomCount;
@Override @Override
boolean shouldLoadOnCreate() { boolean shouldLoadOnCreate() {
@@ -719,6 +907,40 @@ public class ProjectDetailActivityUiTest {
lastReplyWaitBaselineMessageId = baselineMessageId; lastReplyWaitBaselineMessageId = baselineMessageId;
lastReplyWaitIncludeDispatchPlans = includeDispatchPlans; lastReplyWaitIncludeDispatchPlans = includeDispatchPlans;
} }
@Override
void scrollChatToBottom() {
scrollChatToBottomCount += 1;
}
}
private static final class CompletedReplyApiClient extends BossApiClient {
private final JSONObject sendResponse;
int projectDetailCallCount;
CompletedReplyApiClient(JSONObject sendResponse) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.sendResponse = sendResponse;
}
@Override
public ApiResponse sendProjectMessage(String projectId, String body, String kind) {
return new ApiResponse(200, sendResponse);
}
@Override
public ApiResponse getProjectDetail(String projectId) throws org.json.JSONException {
projectDetailCallCount += 1;
return new ApiResponse(
200,
new JSONObject()
.put("ok", true)
.put("project", new JSONObject()
.put("id", projectId)
.put("name", "主 Agent")
.put("messages", new JSONArray()))
);
}
} }
private static final class InMemorySharedPreferences implements SharedPreferences { private static final class InMemorySharedPreferences implements SharedPreferences {

View File

@@ -28,7 +28,7 @@ public class ProjectGoalsActivityUiTest {
TestProjectGoalsActivity activity = Robolectric TestProjectGoalsActivity activity = Robolectric
.buildActivity(TestProjectGoalsActivity.class, new Intent() .buildActivity(TestProjectGoalsActivity.class, new Intent()
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归")) .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
.setup() .setup()
.get(); .get();
@@ -38,16 +38,48 @@ public class ProjectGoalsActivityUiTest {
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()) ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject())
); );
activity.configureScreen("项目目标", "北区试产线回归需要只展示一行避免堆叠");
LinearLayout content = activity.findViewById(R.id.screen_content); LinearLayout content = activity.findViewById(R.id.screen_content);
TextView subtitle = activity.findViewById(R.id.screen_subtitle);
assertTrue(viewTreeContainsText(content, "主 Agent 已整理项目目标 · 已完成 1/3")); assertTrue(viewTreeContainsText(content, "主 Agent 已整理项目目标 · 已完成 1/3"));
assertTrue(viewTreeContainsSubstring(content, "完成北区试产线全链路回归")); assertTrue(viewTreeContainsSubstring(content, "完成北区试产线全链路回归"));
assertTrue(viewTreeContainsSubstring(content, "已完成 · 09:12 由主 Agent 复核")); assertTrue(viewTreeContainsSubstring(content, "已完成 · 09:12 由主 Agent 复核"));
assertTrue(viewTreeContainsText(content, "当前约束")); assertTrue(viewTreeContainsText(content, "当前约束"));
assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12)));
assertTrue(subtitle.getMaxLines() <= 1);
assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END"));
assertFalse(viewTreeContainsText(content, "标记完成")); assertFalse(viewTreeContainsText(content, "标记完成"));
assertFalse(viewTreeContainsText(content, "编辑目标")); assertFalse(viewTreeContainsText(content, "编辑目标"));
assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing()); assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing());
} }
@Test
public void renderGoalsShowsSyncedProjectUnderstandingSummary() throws Exception {
TestProjectGoalsActivity activity = Robolectric
.buildActivity(TestProjectGoalsActivity.class, new Intent()
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGoals",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()
.put("projectUnderstanding", new JSONObject()
.put("projectGoal", "完成北区试产线与主 Agent 接管回归")
.put("currentProgress", "已把最新核对结果同步到项目目标页顶部")
.put("recommendedNextStep", "继续完成 Gitea 推送和真机回归")
.put("updatedAt", "2026-04-18T10:28:00.000Z")))
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "同步项目摘要"));
assertTrue(viewTreeContainsSubstring(content, "完成北区试产线与主 Agent 接管回归"));
assertTrue(viewTreeContainsSubstring(content, "已把最新核对结果同步到项目目标页顶部"));
assertTrue(viewTreeContainsSubstring(content, "继续完成 Gitea 推送和真机回归"));
}
private static JSONObject buildProject() throws Exception { private static JSONObject buildProject() throws Exception {
JSONArray goals = new JSONArray() JSONArray goals = new JSONArray()
.put(new JSONObject() .put(new JSONObject()
@@ -106,6 +138,10 @@ public class ProjectGoalsActivityUiTest {
return false; return false;
} }
private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) {
return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx;
}
public static class TestProjectGoalsActivity extends ProjectGoalsActivity { public static class TestProjectGoalsActivity extends ProjectGoalsActivity {
@Override @Override
protected void reload() { protected void reload() {

View File

@@ -19,7 +19,7 @@ import java.lang.reflect.Method;
@Config(sdk = 34) @Config(sdk = 34)
public class ProjectVersionsActivityTest { public class ProjectVersionsActivityTest {
@Test @Test
public void matchingGoalRefreshMarkerTriggersReload() throws Exception { public void matchingVersionRefreshMarkerTriggersReload() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
@@ -40,7 +40,7 @@ public class ProjectVersionsActivityTest {
"conversation.updated", "conversation.updated",
new JSONObject() new JSONObject()
.put("projectId", "project-1") .put("projectId", "project-1")
.put("note", "project_goals.updated") .put("note", "project_versions.updated")
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -49,7 +49,7 @@ public class ProjectVersionsActivityTest {
} }
@Test @Test
public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception { public void sameProjectNonVersionEventDoesNotTriggerReload() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");

View File

@@ -1,6 +1,7 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.content.Intent; import android.content.Intent;
@@ -28,7 +29,7 @@ public class ProjectVersionsActivityUiTest {
TestProjectVersionsActivity activity = Robolectric TestProjectVersionsActivity activity = Robolectric
.buildActivity(TestProjectVersionsActivity.class, new Intent() .buildActivity(TestProjectVersionsActivity.class, new Intent()
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归")) .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
.setup() .setup()
.get(); .get();
@@ -38,11 +39,18 @@ public class ProjectVersionsActivityUiTest {
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()) ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject())
); );
activity.configureScreen("版本记录", "北区试产线回归需要只展示一行避免堆叠");
LinearLayout content = activity.findViewById(R.id.screen_content); LinearLayout content = activity.findViewById(R.id.screen_content);
TextView title = activity.findViewById(R.id.screen_title);
TextView subtitle = activity.findViewById(R.id.screen_subtitle);
assertEquals("版本记录", String.valueOf(title.getText()));
assertTrue(viewTreeContainsText(content, "仅主 Agent 可发布迭代记录")); assertTrue(viewTreeContainsText(content, "仅主 Agent 可发布迭代记录"));
assertTrue(viewTreeContainsText(content, "v1.2.8 已发布")); assertTrue(viewTreeContainsText(content, "v1.2.8 已发布"));
assertTrue(viewTreeContainsSubstring(content, "• 优化 OTA 实时提示")); assertTrue(viewTreeContainsSubstring(content, "• 优化 OTA 实时提示"));
assertTrue(viewTreeContainsText(content, "主 Agent 复核记录")); assertTrue(viewTreeContainsText(content, "主 Agent 复核记录"));
assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12)));
assertTrue(subtitle.getMaxLines() <= 1);
assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END"));
assertFalse(viewTreeContainsText(content, "版本记录只读")); assertFalse(viewTreeContainsText(content, "版本记录只读"));
assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing()); assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing());
} }
@@ -98,6 +106,10 @@ public class ProjectVersionsActivityUiTest {
return false; return false;
} }
private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) {
return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx;
}
public static class TestProjectVersionsActivity extends ProjectVersionsActivity { public static class TestProjectVersionsActivity extends ProjectVersionsActivity {
@Override @Override
protected void reload() { protected void reload() {

View File

@@ -178,6 +178,7 @@ cd /Users/kris/code/boss
- 当前 release 构建还会额外生成带版本号的 APK`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` - 当前 release 构建还会额外生成带版本号的 APK`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.11``versionCode=24` - 当前最新 release 构建版本:`2.5.11``versionCode=24`
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties` - 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一只使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机
- Android 真机无线调试当前可恢复使用但系统层面没有“永久保持无线调试开启”的官方稳定开关重启、切网、ADB server 重启或重新切换 USB 调试后,都可能自动失效 - Android 真机无线调试当前可恢复使用但系统层面没有“永久保持无线调试开启”的官方稳定开关重启、切网、ADB server 重启或重新切换 USB 调试后,都可能自动失效
- 如果要尽量稳定,当前推荐做法是:同一局域网下先走 USB 启用,再执行 `adb tcpip 5555``adb connect <phone-ip>:5555`;同时固定同一 SSID、避免切热点/VPN、开启“保持唤醒”并保留 USB 作为长时间调试兜底 - 如果要尽量稳定,当前推荐做法是:同一局域网下先走 USB 启用,再执行 `adb tcpip 5555``adb connect <phone-ip>:5555`;同时固定同一 SSID、避免切热点/VPN、开启“保持唤醒”并保留 USB 作为长时间调试兜底
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退 - `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退

View File

@@ -11,6 +11,7 @@ const eslintConfig = defineConfig([
".next/**", ".next/**",
"out/**", "out/**",
"build/**", "build/**",
"main-*.js",
"android/.gradle/**", "android/.gradle/**",
"android/**/build/**", "android/**/build/**",
"next-env.d.ts", "next-env.d.ts",

View File

@@ -7,8 +7,29 @@ function isValidRole(value: string): value is "primary" | "backup" | "api_fallba
return value === "primary" || value === "backup" || value === "api_fallback"; return value === "primary" || value === "backup" || value === "api_fallback";
} }
function isValidProvider(value: string): value is "master_codex_node" | "openai_api" | "aliyun_qwen_api" { function isValidProvider(
return value === "master_codex_node" || value === "openai_api" || value === "aliyun_qwen_api"; value: string,
): value is
| "master_codex_node"
| "google_oauth"
| "chatgpt_oauth"
| "openai_api"
| "aliyun_qwen_api"
| "minimax_api"
| "glm_api"
| "hyzq_api"
| "custom_api" {
return (
value === "master_codex_node" ||
value === "google_oauth" ||
value === "chatgpt_oauth" ||
value === "openai_api" ||
value === "aliyun_qwen_api" ||
value === "minimax_api" ||
value === "glm_api" ||
value === "hyzq_api" ||
value === "custom_api"
);
} }
export async function GET( export async function GET(
@@ -49,6 +70,7 @@ export async function PATCH(
nodeId?: string; nodeId?: string;
nodeLabel?: string; nodeLabel?: string;
model?: string; model?: string;
apiBaseUrl?: string;
apiKey?: string; apiKey?: string;
enabled?: boolean; enabled?: boolean;
setActive?: boolean; setActive?: boolean;
@@ -79,6 +101,7 @@ export async function PATCH(
nodeId: body.nodeId, nodeId: body.nodeId,
nodeLabel: body.nodeLabel, nodeLabel: body.nodeLabel,
model: body.model, model: body.model,
apiBaseUrl: body.apiBaseUrl,
apiKey: body.apiKey, apiKey: body.apiKey,
enabled: body.enabled, enabled: body.enabled,
setActive: body.setActive, setActive: body.setActive,

View File

@@ -24,6 +24,7 @@ export async function POST(request: NextRequest) {
displayName?: string; displayName?: string;
accountIdentifier?: string; accountIdentifier?: string;
model?: string; model?: string;
apiBaseUrl?: string;
apiKey?: string; apiKey?: string;
}; };
@@ -39,6 +40,7 @@ export async function POST(request: NextRequest) {
provider: "aliyun_qwen_api", provider: "aliyun_qwen_api",
apiKey: body.apiKey, apiKey: body.apiKey,
model: body.model, model: body.model,
apiBaseUrl: body.apiBaseUrl,
}); });
const state = await readState(); const state = await readState();
@@ -51,6 +53,7 @@ export async function POST(request: NextRequest) {
displayName: body.displayName.trim(), displayName: body.displayName.trim(),
accountIdentifier: body.accountIdentifier?.trim() || undefined, accountIdentifier: body.accountIdentifier?.trim() || undefined,
model: probe.model, model: probe.model,
apiBaseUrl: body.apiBaseUrl,
apiKey: body.apiKey.trim(), apiKey: body.apiKey.trim(),
enabled: true, enabled: true,
setActive: false, setActive: false,

View File

@@ -24,6 +24,7 @@ export async function POST(request: NextRequest) {
displayName?: string; displayName?: string;
accountIdentifier?: string; accountIdentifier?: string;
model?: string; model?: string;
apiBaseUrl?: string;
apiKey?: string; apiKey?: string;
}; };
@@ -38,6 +39,7 @@ export async function POST(request: NextRequest) {
const probe = await probeOpenAiApiAccount({ const probe = await probeOpenAiApiAccount({
apiKey: body.apiKey, apiKey: body.apiKey,
model: body.model, model: body.model,
apiBaseUrl: body.apiBaseUrl,
}); });
const state = await readState(); const state = await readState();
@@ -50,6 +52,7 @@ export async function POST(request: NextRequest) {
displayName: body.displayName.trim(), displayName: body.displayName.trim(),
accountIdentifier: body.accountIdentifier?.trim() || undefined, accountIdentifier: body.accountIdentifier?.trim() || undefined,
model: probe.model, model: probe.model,
apiBaseUrl: body.apiBaseUrl,
apiKey: body.apiKey.trim(), apiKey: body.apiKey.trim(),
enabled: true, enabled: true,
setActive: true, setActive: true,

View File

@@ -7,8 +7,29 @@ function isValidRole(value: string): value is "primary" | "backup" | "api_fallba
return value === "primary" || value === "backup" || value === "api_fallback"; return value === "primary" || value === "backup" || value === "api_fallback";
} }
function isValidProvider(value: string): value is "master_codex_node" | "openai_api" | "aliyun_qwen_api" { function isValidProvider(
return value === "master_codex_node" || value === "openai_api" || value === "aliyun_qwen_api"; value: string,
): value is
| "master_codex_node"
| "google_oauth"
| "chatgpt_oauth"
| "openai_api"
| "aliyun_qwen_api"
| "minimax_api"
| "glm_api"
| "hyzq_api"
| "custom_api" {
return (
value === "master_codex_node" ||
value === "google_oauth" ||
value === "chatgpt_oauth" ||
value === "openai_api" ||
value === "aliyun_qwen_api" ||
value === "minimax_api" ||
value === "glm_api" ||
value === "hyzq_api" ||
value === "custom_api"
);
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -38,6 +59,7 @@ export async function POST(request: NextRequest) {
nodeId?: string; nodeId?: string;
nodeLabel?: string; nodeLabel?: string;
model?: string; model?: string;
apiBaseUrl?: string;
apiKey?: string; apiKey?: string;
enabled?: boolean; enabled?: boolean;
setActive?: boolean; setActive?: boolean;
@@ -66,6 +88,7 @@ export async function POST(request: NextRequest) {
nodeId: body.nodeId, nodeId: body.nodeId,
nodeLabel: body.nodeLabel, nodeLabel: body.nodeLabel,
model: body.model, model: body.model,
apiBaseUrl: body.apiBaseUrl,
apiKey: body.apiKey, apiKey: body.apiKey,
enabled: body.enabled, enabled: body.enabled,
setActive: body.setActive, setActive: body.setActive,

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { validateAiAccountDraftConnection } from "@/lib/boss-master-agent";
function isValidProvider(
value: string,
): value is
| "openai_api"
| "aliyun_qwen_api"
| "minimax_api"
| "glm_api"
| "hyzq_api"
| "custom_api" {
return (
value === "openai_api" ||
value === "aliyun_qwen_api" ||
value === "minimax_api" ||
value === "glm_api" ||
value === "hyzq_api" ||
value === "custom_api"
);
}
export async function POST(request: NextRequest) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
if (session.role !== "highest_admin") {
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
}
const body = (await request.json()) as {
provider?: string;
apiKey?: string;
apiBaseUrl?: string;
};
if (!body.provider || !isValidProvider(body.provider)) {
return NextResponse.json({ ok: false, message: "API 接入商不合法。" }, { status: 400 });
}
if (!body.apiKey?.trim()) {
return NextResponse.json({ ok: false, message: "API Key 不能为空。" }, { status: 400 });
}
try {
const result = await validateAiAccountDraftConnection({
provider: body.provider,
apiKey: body.apiKey,
apiBaseUrl: body.apiBaseUrl,
});
return NextResponse.json(result, { status: 200 });
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: 400 },
);
}
}

View File

@@ -56,6 +56,8 @@ export async function POST(
const payload = body as { const payload = body as {
modelOverride?: unknown; modelOverride?: unknown;
reasoningEffortOverride?: unknown; reasoningEffortOverride?: unknown;
fastModelOverride?: unknown;
deepModelOverride?: unknown;
promptOverride?: unknown; promptOverride?: unknown;
backendOverride?: unknown; backendOverride?: unknown;
takeoverEnabled?: unknown; takeoverEnabled?: unknown;
@@ -66,6 +68,8 @@ export async function POST(
payload, payload,
"reasoningEffortOverride", "reasoningEffortOverride",
); );
const hasFastModelOverride = Object.prototype.hasOwnProperty.call(payload, "fastModelOverride");
const hasDeepModelOverride = Object.prototype.hasOwnProperty.call(payload, "deepModelOverride");
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,6 +79,8 @@ export async function POST(
? new Set([ ? new Set([
"modelOverride", "modelOverride",
"reasoningEffortOverride", "reasoningEffortOverride",
"fastModelOverride",
"deepModelOverride",
"promptOverride", "promptOverride",
"backendOverride", "backendOverride",
"globalTakeoverEnabled", "globalTakeoverEnabled",
@@ -85,6 +91,8 @@ export async function POST(
( (
!hasModelOverride && !hasModelOverride &&
!hasReasoningEffortOverride && !hasReasoningEffortOverride &&
!hasFastModelOverride &&
!hasDeepModelOverride &&
!hasPromptOverride && !hasPromptOverride &&
!hasBackendOverride && !hasBackendOverride &&
!hasTakeoverEnabled && !hasTakeoverEnabled &&
@@ -110,6 +118,12 @@ 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 (hasDeepModelOverride && payload.deepModelOverride !== undefined && payload.deepModelOverride !== null && typeof payload.deepModelOverride !== "string") {
return NextResponse.json({ ok: false, message: "INVALID_DEEP_MODEL_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 });
} }
@@ -154,6 +168,8 @@ export async function POST(
{ {
...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}), ...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}),
...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}), ...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}),
...(hasFastModelOverride ? { fastModelOverride: payload.fastModelOverride } : {}),
...(hasDeepModelOverride ? { deepModelOverride: payload.deepModelOverride } : {}),
...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}), ...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}),
...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}), ...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}),
...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}), ...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}),

View File

@@ -1,6 +1,13 @@
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 { appendProjectMessage, buildCollaborationGate, readState } from "@/lib/boss-data"; import {
appendProjectMessage,
appendProjectMessages,
buildCollaborationGate,
getProjectAgentControls,
readState,
requestProjectUnderstandingSyncForProject,
} from "@/lib/boss-data";
import { jsonNoStore } from "@/lib/api-response"; import { jsonNoStore } from "@/lib/api-response";
import { buildProjectMessagesRealtimePayload } from "@/lib/boss-projections"; import { buildProjectMessagesRealtimePayload } from "@/lib/boss-projections";
import { import {
@@ -10,6 +17,7 @@ import {
replyToMasterAgentUserMessage, replyToMasterAgentUserMessage,
shouldRecommendMasterAgentDispatchPlan, shouldRecommendMasterAgentDispatchPlan,
ThreadConversationExecutionConflictError, ThreadConversationExecutionConflictError,
tryBuildLocalMasterAgentFastReply,
} from "@/lib/boss-master-agent"; } from "@/lib/boss-master-agent";
import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy"; import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy";
@@ -105,14 +113,19 @@ export async function POST(
); );
} }
const singleThreadExecutionConflict = const isSingleThreadTextMessage =
project && Boolean(project) &&
projectId !== "master-agent" && projectId !== "master-agent" &&
!project.isGroup && !project?.isGroup &&
(body.kind ?? "text") === "text" && (body.kind ?? "text") === "text" &&
(body.body ?? "").trim().length > 0 (body.body ?? "").trim().length > 0;
? await getThreadConversationExecutionConflict(projectId) const singleThreadAgentControls = isSingleThreadTextMessage
: null; ? await getProjectAgentControls(projectId, session.account)
: null;
const singleThreadTakeoverEnabled = singleThreadAgentControls?.effectiveTakeoverEnabled === true;
const singleThreadExecutionConflict = isSingleThreadTextMessage && !singleThreadTakeoverEnabled
? await getThreadConversationExecutionConflict(projectId)
: null;
if (singleThreadExecutionConflict) { if (singleThreadExecutionConflict) {
return NextResponse.json( return NextResponse.json(
@@ -126,6 +139,49 @@ export async function POST(
); );
} }
if (projectId === "master-agent" && (body.kind ?? "text") === "text" && (body.body ?? "").trim()) {
const localMasterReply = await tryBuildLocalMasterAgentFastReply({
requestText: (body.body ?? "").trim(),
requestedByAccount: session.account,
projectId,
state,
});
if (localMasterReply) {
const [message, replyMessage] = await appendProjectMessages({
projectId,
messages: [
{
senderLabel: session.displayName || "你",
body: body.body,
kind: body.kind ?? "text",
},
{
sender: "master",
senderLabel: localMasterReply.senderLabel,
body: localMasterReply.replyBody,
kind: "text",
},
],
});
return NextResponse.json({
ok: true,
message,
replyMessage,
masterReply: localMasterReply.masterReply,
task: null,
replyPresenter: "master",
masterReplyState: "completed",
dispatchPlan: null,
dispatchRecommendation: {
ok: false,
status: "skipped",
},
collaborationGate: buildCollaborationGate(project),
});
}
}
const message = await appendProjectMessage({ const message = await appendProjectMessage({
projectId, projectId,
senderLabel: session.displayName || "你", senderLabel: session.displayName || "你",
@@ -155,8 +211,10 @@ export async function POST(
taskType: "conversation_reply"; taskType: "conversation_reply";
status: "queued" | "running" | "completed"; status: "queued" | "running" | "completed";
}; };
replyMessage?: Awaited<ReturnType<typeof appendProjectMessage>>;
} }
| undefined; | undefined;
let replyMessage: Awaited<ReturnType<typeof appendProjectMessage>> | undefined;
let task: let task:
| { | {
taskId: string; taskId: string;
@@ -169,6 +227,7 @@ export async function POST(
| "running" | "running"
| "completed" | "completed"
| null = null; | null = null;
let replyPresenter: "thread" | "master" | undefined;
if (shouldCreateDispatchPlan) { if (shouldCreateDispatchPlan) {
try { try {
@@ -204,18 +263,49 @@ export async function POST(
}); });
} }
} else if (project && projectId !== "master-agent" && !project.isGroup && message.body.trim().length > 0) { } else if (project && projectId !== "master-agent" && !project.isGroup && message.body.trim().length > 0) {
const queuedTask = await queueThreadConversationReplyTask({ const relayViaMasterAgent = singleThreadTakeoverEnabled;
projectId, if (relayViaMasterAgent) {
requestMessageId: message.id, if (shouldRequestVerifiedProjectSummarySync(message.body)) {
requestText: message.body, await requestProjectUnderstandingSyncForProject({
requestedBy: session.displayName || session.account, projectId,
requestedByAccount: session.account, observedActivityAt: message.sentAt,
}); reason: "thread_reply",
task = { });
taskId: queuedTask.taskId, }
taskType: "conversation_reply", masterReply = await replyToMasterAgentUserMessage({
status: "queued", requestMessageId: message.id,
}; requestText: message.body,
requestedBy: session.displayName || session.account,
requestedByAccount: session.account,
currentSessionExpiresAt: session.expiresAt,
projectId,
interactionMode: "takeover_single_thread",
mode: "enqueue",
})
if (masterReply?.taskId) {
task = masterReply.task ?? {
taskId: masterReply.taskId,
taskType: "conversation_reply",
status: masterReply.masterReplyState ?? "queued",
};
masterReplyState = masterReply.masterReplyState ?? null;
}
replyMessage = masterReply?.replyMessage;
} else {
const queuedTask = await queueThreadConversationReplyTask({
projectId,
requestMessageId: message.id,
requestText: message.body,
requestedBy: session.displayName || session.account,
requestedByAccount: session.account,
});
task = {
taskId: queuedTask.taskId,
taskType: "conversation_reply",
status: "queued",
};
}
replyPresenter = relayViaMasterAgent ? "master" : "thread";
} else { } else {
dispatchRecommendation = { dispatchRecommendation = {
ok: false, ok: false,
@@ -230,11 +320,19 @@ export async function POST(
requestedBy: session.displayName, requestedBy: session.displayName,
requestedByAccount: session.account, requestedByAccount: session.account,
currentSessionExpiresAt: session.expiresAt, currentSessionExpiresAt: session.expiresAt,
mode: "enqueue", mode: "smart",
}); });
if (masterReply?.ok && masterReply.taskId) { if (masterReply?.ok) {
task = masterReply.task ?? null; if (masterReply.taskId) {
masterReplyState = masterReply.masterReplyState ?? null; task = masterReply.task ?? {
taskId: masterReply.taskId,
taskType: "conversation_reply",
status: masterReply.masterReplyState ?? "queued",
};
}
masterReplyState = masterReply.masterReplyState ?? (masterReply.taskId ? null : "completed");
replyPresenter = "master";
replyMessage = masterReply.replyMessage;
} else { } else {
masterReplyState = null; masterReplyState = null;
} }
@@ -247,8 +345,10 @@ export async function POST(
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
message, message,
replyMessage,
masterReply, masterReply,
task, task,
replyPresenter,
masterReplyState, masterReplyState,
dispatchPlan, dispatchPlan,
dispatchRecommendation, dispatchRecommendation,
@@ -277,3 +377,14 @@ export async function POST(
); );
} }
} }
function shouldRequestVerifiedProjectSummarySync(text: string) {
const normalized = text.trim();
if (!normalized) {
return false;
}
const mentionsGoal = /项目目标|目标/.test(normalized);
const mentionsVersion = /版本记录|版本迭代|版本/.test(normalized);
const mentionsReviewOrSync = /核对|确认|同步|更新|刷新|整理|汇总/.test(normalized);
return mentionsReviewOrSync && (mentionsGoal || mentionsVersion);
}

View File

@@ -23,6 +23,15 @@ export default async function GoalsPage({
if (!project) notFound(); if (!project) notFound();
const completedCount = project.goals.filter((item) => item.state === "completed").length; const completedCount = project.goals.filter((item) => item.state === "completed").length;
const understandingUpdatedAt = project.projectUnderstanding?.updatedAt
? new Date(project.projectUnderstanding.updatedAt).toLocaleString("zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: null;
return ( return (
<AppShell bottomNav={false}> <AppShell bottomNav={false}>
@@ -42,6 +51,34 @@ export default async function GoalsPage({
09:18 · · 线 09:18 · · 线
</div> </div>
</div> </div>
{project.projectUnderstanding ? (
<div className="rounded-2xl border border-[#D6EEDC] bg-[#F5FBF7] px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[15px] font-semibold text-[#215B39]"></div>
<div className="text-[12px] text-[#6B8A77]">{understandingUpdatedAt ?? "刚刚更新"}</div>
</div>
<div className="mt-3 space-y-3">
<div className="rounded-2xl bg-white/80 px-3 py-3">
<div className="text-[12px] font-semibold text-[#6B8A77]"></div>
<div className="mt-1 text-[14px] leading-6 text-[#215B39]">
{project.projectUnderstanding?.projectGoal}
</div>
</div>
<div className="rounded-2xl bg-white/80 px-3 py-3">
<div className="text-[12px] font-semibold text-[#6B8A77]"></div>
<div className="mt-1 text-[14px] leading-6 text-[#215B39]">
{project.projectUnderstanding?.currentProgress}
</div>
</div>
<div className="rounded-2xl bg-white/80 px-3 py-3">
<div className="text-[12px] font-semibold text-[#6B8A77]"></div>
<div className="mt-1 text-[14px] leading-6 text-[#215B39]">
{project.projectUnderstanding?.recommendedNextStep}
</div>
</div>
</div>
</div>
) : null}
<GoalChecklist projectId={projectId} goals={project.goals} /> <GoalChecklist projectId={projectId} goals={project.goals} />
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-4"> <div className="rounded-2xl bg-[#EAF7F0] px-4 py-4">
<div className="text-[14px] font-semibold text-[#215B39]"></div> <div className="text-[14px] font-semibold text-[#215B39]"></div>

View File

@@ -26,10 +26,10 @@ export default async function VersionsPage({
<RealtimeRefresh <RealtimeRefresh
projectId={projectId} projectId={projectId}
events={["conversation.updated", "project.messages.updated", "ota.updated"]} events={["conversation.updated", "project.messages.updated", "ota.updated"]}
conversationUpdatedNotes={["project_goals.updated"]} conversationUpdatedNotes={["project_versions.updated"]}
/> />
<StatusBar /> <StatusBar />
<PageNav title="版本迭代记录" backHref={`/conversations/${projectId}`} /> <PageNav title="版本记录" backHref={`/conversations/${projectId}`} />
<div className="flex flex-col gap-3 px-[18px] pb-6"> <div className="flex flex-col gap-3 px-[18px] pb-6">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]"> <div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
Agent 线 Agent 线

View File

@@ -25,6 +25,7 @@ import type {
ThreadConversationExecutionConflict, ThreadConversationExecutionConflict,
ThreadConversationExecutionConflictAction, ThreadConversationExecutionConflictAction,
} from "@/lib/thread-execution-conflict"; } from "@/lib/thread-execution-conflict";
import { parseChatMarkdown, type ChatMarkdownBlock } from "@/lib/chat-markdown";
import { import {
describeThreadConversationExecutionConflict, describeThreadConversationExecutionConflict,
labelForProjectConflictAllowPolicy, labelForProjectConflictAllowPolicy,
@@ -907,13 +908,101 @@ export function ChatBubble({ message }: { message: Message }) {
{tag ? ( {tag ? (
<div className="mb-2 text-[11px] font-semibold opacity-80">{tag}</div> <div className="mb-2 text-[11px] font-semibold opacity-80">{tag}</div>
) : null} ) : null}
{message.body} <ChatBubbleMarkdown body={message.body} mine={mine} green={green} />
</div> </div>
</div> </div>
</div> </div>
); );
} }
function ChatBubbleMarkdown({
body,
mine,
green,
}: {
body: string;
mine: boolean;
green: boolean;
}) {
const blocks = parseChatMarkdown(body);
return (
<div className="space-y-2 break-words">
{blocks.map((block, index) => (
<ChatMarkdownBlockView key={`${block.kind}-${index}`} block={block} mine={mine} green={green} />
))}
</div>
);
}
function ChatMarkdownBlockView({
block,
mine,
green,
}: {
block: ChatMarkdownBlock;
mine: boolean;
green: boolean;
}) {
const mutedClass = mine ? "text-white/82" : green ? "text-[#4E7A60]" : "text-[#57606A]";
const markerClass = mine ? "text-white/72" : green ? "text-[#44A064]" : "text-[#8C8C8C]";
switch (block.kind) {
case "heading":
return (
<div
className={clsx(
"font-semibold leading-6",
block.level === 1 ? "text-[16px]" : block.level === 2 ? "text-[15px]" : "text-[14px]",
)}
>
{block.text}
</div>
);
case "label":
return (
<div className="rounded-2xl bg-black/[0.035] px-3 py-2">
<div className={clsx("text-[12px] font-semibold", markerClass)}>{block.label}</div>
<div className="mt-1 whitespace-pre-wrap text-[14px] leading-6">{block.text}</div>
</div>
);
case "bullet":
return (
<div className="flex gap-2 leading-6">
<span className={markerClass}></span>
<span className="min-w-0 flex-1">{block.text}</span>
</div>
);
case "ordered":
return (
<div className="flex gap-2 leading-6">
<span className={clsx("tabular-nums", markerClass)}>{block.order}</span>
<span className="min-w-0 flex-1">{block.text}</span>
</div>
);
case "quote":
return (
<div className={clsx("border-l-2 pl-3 text-[14px] leading-6", mine ? "border-white/50" : "border-[#D8DEE4]", mutedClass)}>
{block.text}
</div>
);
case "code":
return (
<pre
className={clsx(
"overflow-x-auto rounded-2xl px-3 py-2 text-[12px] leading-5",
mine ? "bg-white/16 text-white" : "bg-[#F2F3F5] text-[#24292F]",
)}
>
<code>{block.text}</code>
</pre>
);
case "paragraph":
default:
return <div className="whitespace-pre-wrap leading-6">{block.text}</div>;
}
}
export function ProjectHeaderActions({ projectId }: { projectId: string }) { export function ProjectHeaderActions({ projectId }: { projectId: string }) {
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">

View File

@@ -12,6 +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 { getMasterAgentModelOptions } from "@/lib/master-agent-model-options";
import { formatTimestampLabel } from "@/lib/boss-projections"; import { formatTimestampLabel } from "@/lib/boss-projections";
type MemoryDraft = { type MemoryDraft = {
@@ -191,6 +192,7 @@ export function MasterAgentPromptMemoryClient({
}); });
const allMemories = useMemo(() => [...projectMemories, ...globalMemories], [projectMemories, globalMemories]); const allMemories = useMemo(() => [...projectMemories, ...globalMemories], [projectMemories, globalMemories]);
const modelOptions = useMemo(() => getMasterAgentModelOptions(modelOverride), [modelOverride]);
const promptPreview = useMemo(() => { const promptPreview = useMemo(() => {
const sections = [ const sections = [
globalPrompt.trim() ? `【管理员全局主提示词】\n${globalPrompt.trim()}` : null, globalPrompt.trim() ? `【管理员全局主提示词】\n${globalPrompt.trim()}` : null,
@@ -431,9 +433,11 @@ export function MasterAgentPromptMemoryClient({
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none" 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=""></option>
<option value="gpt-5.4">gpt-5.4</option> {modelOptions.map((option) => (
<option value="gpt-4.1">gpt-4.1</option> <option key={option} value={option}>
<option value="gpt-4.1-mini">gpt-4.1-mini</option> {option}
</option>
))}
</select> </select>
</label> </label>
<label id={anchors.reasoningEffort.split("#")[1]} className="space-y-1 scroll-mt-4"> <label id={anchors.reasoningEffort.split("#")[1]} className="space-y-1 scroll-mt-4">

View File

@@ -13,6 +13,7 @@ import {
type OmxTeamBackendSelectionState, type OmxTeamBackendSelectionState,
} from "@/lib/execution/backends/omx-team-backend"; } from "@/lib/execution/backends/omx-team-backend";
import { selectOrchestrationBackend } from "@/lib/execution/orchestration-backend-selector"; import { selectOrchestrationBackend } from "@/lib/execution/orchestration-backend-selector";
import { hasRecentThreadConversationExternalActivity } from "@/lib/thread-execution-conflict";
export type DeviceStatus = "online" | "abnormal" | "offline"; export type DeviceStatus = "online" | "abnormal" | "offline";
export type DeviceSource = "production" | "demo"; export type DeviceSource = "production" | "demo";
@@ -137,7 +138,16 @@ export type ProjectConflictState = "none" | "warning" | "blocked";
export type OtaUpdateStatus = "available" | "scheduled" | "applied" | "skipped"; export type OtaUpdateStatus = "available" | "scheduled" | "applied" | "skipped";
export type OtaLogStatus = "checked" | "applied" | "skipped"; export type OtaLogStatus = "checked" | "applied" | "skipped";
export type AppLogLevel = "info" | "warn" | "error"; export type AppLogLevel = "info" | "warn" | "error";
export type AiProvider = "master_codex_node" | "openai_api" | "aliyun_qwen_api"; export type AiProvider =
| "master_codex_node"
| "google_oauth"
| "chatgpt_oauth"
| "openai_api"
| "aliyun_qwen_api"
| "minimax_api"
| "glm_api"
| "hyzq_api"
| "custom_api";
export type AiAccountRole = "primary" | "backup" | "api_fallback"; export type AiAccountRole = "primary" | "backup" | "api_fallback";
export type AiAccountStatus = "ready" | "needs_login" | "needs_api_key" | "degraded" | "disabled"; export type AiAccountStatus = "ready" | "needs_login" | "needs_api_key" | "degraded" | "disabled";
export type MasterAgentTaskStatus = "queued" | "running" | "completed" | "failed"; export type MasterAgentTaskStatus = "queued" | "running" | "completed" | "failed";
@@ -285,6 +295,15 @@ export interface VersionEntry {
createdAt: string; createdAt: string;
} }
type ProjectUnderstandingSyncReply = {
projectGoal?: string;
currentProgress?: string;
technicalArchitecture?: string;
currentBlockers?: string;
recommendedNextStep?: string;
versionRecord?: string;
};
export interface ThreadConversationMeta { export interface ThreadConversationMeta {
projectId: string; projectId: string;
threadId: string; threadId: string;
@@ -405,6 +424,8 @@ export function buildCollaborationGate(
export interface ProjectAgentControls { export interface ProjectAgentControls {
modelOverride?: string; modelOverride?: string;
reasoningEffortOverride?: ReasoningEffort; reasoningEffortOverride?: ReasoningEffort;
fastModelOverride?: string;
deepModelOverride?: string;
promptOverride?: string; promptOverride?: string;
backendOverride?: "claw-runtime"; backendOverride?: "claw-runtime";
takeoverEnabled?: boolean; takeoverEnabled?: boolean;
@@ -658,6 +679,7 @@ export interface AiAccount {
nodeId?: string; nodeId?: string;
nodeLabel?: string; nodeLabel?: string;
model?: string; model?: string;
apiBaseUrl?: string;
apiKey?: string; apiKey?: string;
apiKeyMasked?: string; apiKeyMasked?: string;
enabled: boolean; enabled: boolean;
@@ -696,6 +718,7 @@ export interface AiAccountSummary {
nodeId?: string; nodeId?: string;
nodeLabel?: string; nodeLabel?: string;
model?: string; model?: string;
apiBaseUrl?: string;
enabled: boolean; enabled: boolean;
isActive: boolean; isActive: boolean;
canGenerate: boolean; canGenerate: boolean;
@@ -764,6 +787,7 @@ export interface MasterAgentTask {
deviceImportCandidateFolderName?: string; deviceImportCandidateFolderName?: string;
projectUnderstandingTargetProjectId?: string; projectUnderstandingTargetProjectId?: string;
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply"; projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
relayViaMasterAgent?: boolean;
status: MasterAgentTaskStatus; status: MasterAgentTaskStatus;
requestedAt: string; requestedAt: string;
claimedAt?: string; claimedAt?: string;
@@ -1092,6 +1116,7 @@ const VERIFICATION_SEND_WINDOW_LIMIT = 5;
export const AUTH_SESSION_TTL_MS = 30 * 24 * 60 * 60_000; export const AUTH_SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
const AUTH_LOGIN_LOCK_THRESHOLD = 5; const AUTH_LOGIN_LOCK_THRESHOLD = 5;
const AUTH_LOGIN_LOCK_MS = 10 * 60_000; const AUTH_LOGIN_LOCK_MS = 10 * 60_000;
const THREAD_STATUS_FULL_SYNC_INTERVAL_MS = 30 * 60_000;
const ENV_OPENAI_ACCOUNT_ID = "env-openai-api"; const ENV_OPENAI_ACCOUNT_ID = "env-openai-api";
function baseThreadChecklist(labels: string[]) { function baseThreadChecklist(labels: string[]) {
@@ -2352,6 +2377,8 @@ function normalizeProjectAgentControls(
const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride) const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride)
? raw.reasoningEffortOverride ? raw.reasoningEffortOverride
: undefined; : undefined;
const fastModelOverride = trimToDefined(raw?.fastModelOverride);
const deepModelOverride = trimToDefined(raw?.deepModelOverride);
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 : undefined;
const takeoverEnabled = typeof raw?.takeoverEnabled === "boolean" ? raw.takeoverEnabled : undefined; const takeoverEnabled = typeof raw?.takeoverEnabled === "boolean" ? raw.takeoverEnabled : undefined;
@@ -2361,6 +2388,8 @@ function normalizeProjectAgentControls(
if ( if (
!modelOverride && !modelOverride &&
!reasoningEffortOverride && !reasoningEffortOverride &&
!fastModelOverride &&
!deepModelOverride &&
!promptOverride && !promptOverride &&
!backendOverride && !backendOverride &&
takeoverEnabled === undefined && takeoverEnabled === undefined &&
@@ -2372,6 +2401,8 @@ function normalizeProjectAgentControls(
return { return {
modelOverride, modelOverride,
reasoningEffortOverride, reasoningEffortOverride,
fastModelOverride,
deepModelOverride,
promptOverride, promptOverride,
backendOverride, backendOverride,
takeoverEnabled, takeoverEnabled,
@@ -2492,15 +2523,61 @@ export function aiProviderLabel(provider: AiProvider) {
switch (provider) { switch (provider) {
case "master_codex_node": case "master_codex_node":
return "Master Codex Node / ChatGPT Plus 节点"; return "Master Codex Node / ChatGPT Plus 节点";
case "google_oauth":
return "谷歌登录";
case "chatgpt_oauth":
return "ChatGPT登录";
case "openai_api": case "openai_api":
return "OpenAI API"; return "OpenAI API";
case "aliyun_qwen_api": case "aliyun_qwen_api":
return "阿里百炼 Qwen"; return "阿里百炼 Qwen";
case "minimax_api":
return "MiniMax API";
case "glm_api":
return "GLM API";
case "hyzq_api":
return "环宇智擎 API";
case "custom_api":
return "自定义 API";
default: default:
return provider; return provider;
} }
} }
export function aiProviderDefaultApiBaseUrl(provider: AiProvider) {
switch (provider) {
case "openai_api":
return "https://api.openai.com/v1";
case "aliyun_qwen_api":
return "https://dashscope.aliyuncs.com/compatible-mode/v1";
case "minimax_api":
return "https://api.minimaxi.com/v1";
case "glm_api":
return "https://open.bigmodel.cn/api/paas/v4";
case "hyzq_api":
return "https://api.hyzq2046.com/v1";
default:
return undefined;
}
}
export function aiProviderDefaultModel(provider: AiProvider) {
switch (provider) {
case "openai_api":
return "gpt-5.4";
case "aliyun_qwen_api":
return "qwen3.5-plus";
case "minimax_api":
return "MiniMax-M1";
case "glm_api":
return "glm-4.5";
case "hyzq_api":
return "gpt-5.4-mini";
default:
return undefined;
}
}
export function aiStatusLabel(status: AiAccountStatus) { export function aiStatusLabel(status: AiAccountStatus) {
switch (status) { switch (status) {
case "ready": case "ready":
@@ -2525,8 +2602,20 @@ function maskApiKey(value?: string) {
return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`; return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
} }
function isApiKeyProvider(provider: AiProvider) { function normalizeApiBaseUrl(value?: string) {
return provider === "openai_api" || provider === "aliyun_qwen_api"; if (!value?.trim()) return undefined;
return value.trim().replace(/\/+$/, "");
}
export function isApiKeyProvider(provider: AiProvider) {
return (
provider === "openai_api" ||
provider === "aliyun_qwen_api" ||
provider === "minimax_api" ||
provider === "glm_api" ||
provider === "hyzq_api" ||
provider === "custom_api"
);
} }
function deriveAiAccountStatus(account: AiAccount): AiAccountStatus { function deriveAiAccountStatus(account: AiAccount): AiAccountStatus {
@@ -2585,6 +2674,7 @@ function buildAiAccountSummary(account: AiAccount, options?: { isEnvironmentFall
nodeId: normalized.nodeId, nodeId: normalized.nodeId,
nodeLabel: normalized.nodeLabel, nodeLabel: normalized.nodeLabel,
model: normalized.model, model: normalized.model,
apiBaseUrl: normalized.apiBaseUrl,
enabled: normalized.enabled, enabled: normalized.enabled,
isActive: normalized.isActive, isActive: normalized.isActive,
canGenerate: aiAccountCanGenerate(normalized), canGenerate: aiAccountCanGenerate(normalized),
@@ -2615,6 +2705,7 @@ function getEnvOpenAiAccount() {
provider: "openai_api", provider: "openai_api",
displayName: "环境变量 OpenAI API", displayName: "环境变量 OpenAI API",
model: process.env.OPENAI_MODEL?.trim() || "gpt-5.4", model: process.env.OPENAI_MODEL?.trim() || "gpt-5.4",
apiBaseUrl: normalizeApiBaseUrl(process.env.OPENAI_API_BASE_URL),
apiKey, apiKey,
apiKeyMasked: maskApiKey(apiKey), apiKeyMasked: maskApiKey(apiKey),
enabled: true, enabled: true,
@@ -3252,6 +3343,8 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
targetProjectId: task.targetProjectId, targetProjectId: task.targetProjectId,
targetThreadId: task.targetThreadId, targetThreadId: task.targetThreadId,
targetThreadDisplayName: task.targetThreadDisplayName, targetThreadDisplayName: task.targetThreadDisplayName,
targetCodexThreadRef: task.targetCodexThreadRef,
targetCodexFolderRef: task.targetCodexFolderRef,
orchestrationBackendId: orchestrationBackendId:
task.orchestrationBackendId === "omx-team" || task.orchestrationBackendId === "boss-native-orchestrator" task.orchestrationBackendId === "omx-team" || task.orchestrationBackendId === "boss-native-orchestrator"
? task.orchestrationBackendId ? task.orchestrationBackendId
@@ -3265,6 +3358,7 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
task.projectUnderstandingReason === "heartbeat_activity" || task.projectUnderstandingReason === "thread_reply" task.projectUnderstandingReason === "heartbeat_activity" || task.projectUnderstandingReason === "thread_reply"
? task.projectUnderstandingReason ? task.projectUnderstandingReason
: undefined, : undefined,
relayViaMasterAgent: task.relayViaMasterAgent === true ? true : undefined,
status: task.status ?? "queued", status: task.status ?? "queued",
requestedAt: task.requestedAt ?? nowIso(), requestedAt: task.requestedAt ?? nowIso(),
claimedAt: task.claimedAt, claimedAt: task.claimedAt,
@@ -4260,6 +4354,8 @@ export async function updateProjectAgentControls(
payload: { payload: {
modelOverride?: unknown; modelOverride?: unknown;
reasoningEffortOverride?: unknown; reasoningEffortOverride?: unknown;
fastModelOverride?: unknown;
deepModelOverride?: unknown;
promptOverride?: unknown; promptOverride?: unknown;
backendOverride?: unknown; backendOverride?: unknown;
takeoverEnabled?: unknown; takeoverEnabled?: unknown;
@@ -4278,6 +4374,12 @@ export async function updateProjectAgentControls(
const reasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "reasoningEffortOverride") const reasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "reasoningEffortOverride")
? parseReasoningEffortOverride(payload.reasoningEffortOverride) ? parseReasoningEffortOverride(payload.reasoningEffortOverride)
: { kind: "preserve" as const }; : { kind: "preserve" as const };
const fastModelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "fastModelOverride")
? parseControlTextOverride(payload.fastModelOverride)
: { kind: "preserve" as const };
const deepModelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "deepModelOverride")
? parseControlTextOverride(payload.deepModelOverride)
: { kind: "preserve" as const };
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 };
@@ -4296,6 +4398,12 @@ export async function updateProjectAgentControls(
if (reasoningEffortInput.kind === "invalid") { if (reasoningEffortInput.kind === "invalid") {
throw new Error("INVALID_REASONING_EFFORT_OVERRIDE"); throw new Error("INVALID_REASONING_EFFORT_OVERRIDE");
} }
if (fastModelOverrideInput.kind === "invalid") {
throw new Error("INVALID_FAST_MODEL_OVERRIDE");
}
if (deepModelOverrideInput.kind === "invalid") {
throw new Error("INVALID_DEEP_MODEL_OVERRIDE");
}
if (promptOverrideInput.kind === "invalid") { if (promptOverrideInput.kind === "invalid") {
throw new Error("INVALID_PROMPT_OVERRIDE"); throw new Error("INVALID_PROMPT_OVERRIDE");
} }
@@ -4312,6 +4420,8 @@ export async function updateProjectAgentControls(
if ( if (
modelOverrideInput.kind !== "preserve" || modelOverrideInput.kind !== "preserve" ||
reasoningEffortInput.kind !== "preserve" || reasoningEffortInput.kind !== "preserve" ||
fastModelOverrideInput.kind !== "preserve" ||
deepModelOverrideInput.kind !== "preserve" ||
promptOverrideInput.kind !== "preserve" || promptOverrideInput.kind !== "preserve" ||
backendOverrideInput.kind !== "preserve" || backendOverrideInput.kind !== "preserve" ||
globalTakeoverEnabledInput.kind !== "preserve" globalTakeoverEnabledInput.kind !== "preserve"
@@ -4341,6 +4451,18 @@ export async function updateProjectAgentControls(
: reasoningEffortInput.kind === "clear" : reasoningEffortInput.kind === "clear"
? undefined ? undefined
: currentControls?.reasoningEffortOverride; : currentControls?.reasoningEffortOverride;
const fastModelOverride =
fastModelOverrideInput.kind === "set"
? fastModelOverrideInput.value
: fastModelOverrideInput.kind === "clear"
? undefined
: currentControls?.fastModelOverride;
const deepModelOverride =
deepModelOverrideInput.kind === "set"
? deepModelOverrideInput.value
: deepModelOverrideInput.kind === "clear"
? undefined
: currentControls?.deepModelOverride;
const promptOverride = const promptOverride =
promptOverrideInput.kind === "set" promptOverrideInput.kind === "set"
? promptOverrideInput.value ? promptOverrideInput.value
@@ -4368,6 +4490,8 @@ export async function updateProjectAgentControls(
const currentModelOverride = currentControls?.modelOverride; const currentModelOverride = currentControls?.modelOverride;
const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride; const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride;
const currentFastModelOverride = currentControls?.fastModelOverride;
const currentDeepModelOverride = currentControls?.deepModelOverride;
const currentPromptOverride = currentControls?.promptOverride; const currentPromptOverride = currentControls?.promptOverride;
const currentBackendOverride = currentControls?.backendOverride; const currentBackendOverride = currentControls?.backendOverride;
const currentTakeoverEnabled = currentControls?.takeoverEnabled; const currentTakeoverEnabled = currentControls?.takeoverEnabled;
@@ -4375,6 +4499,8 @@ export async function updateProjectAgentControls(
if ( if (
currentModelOverride === modelOverride && currentModelOverride === modelOverride &&
currentReasoningEffortOverride === reasoningEffortOverride && currentReasoningEffortOverride === reasoningEffortOverride &&
currentFastModelOverride === fastModelOverride &&
currentDeepModelOverride === deepModelOverride &&
currentPromptOverride === promptOverride && currentPromptOverride === promptOverride &&
currentBackendOverride === backendOverride && currentBackendOverride === backendOverride &&
currentTakeoverEnabled === takeoverEnabled && currentTakeoverEnabled === takeoverEnabled &&
@@ -4394,6 +4520,8 @@ export async function updateProjectAgentControls(
const nextControls = { const nextControls = {
modelOverride, modelOverride,
reasoningEffortOverride, reasoningEffortOverride,
fastModelOverride,
deepModelOverride,
promptOverride, promptOverride,
backendOverride, backendOverride,
takeoverEnabled, takeoverEnabled,
@@ -5522,6 +5650,7 @@ export async function saveAiAccount(payload: {
nodeId?: string; nodeId?: string;
nodeLabel?: string; nodeLabel?: string;
model?: string; model?: string;
apiBaseUrl?: string;
apiKey?: string; apiKey?: string;
enabled?: boolean; enabled?: boolean;
setActive?: boolean; setActive?: boolean;
@@ -5535,12 +5664,9 @@ export async function saveAiAccount(payload: {
existing?.accountId ?? existing?.accountId ??
payload.accountId?.trim() ?? payload.accountId?.trim() ??
`ai-${slugify(`${payload.label}-${payload.displayName}`)}`; `ai-${slugify(`${payload.label}-${payload.displayName}`)}`;
const providerChanged = Boolean(existing && existing.provider !== payload.provider);
const defaultModel = const defaultModel =
payload.provider === "aliyun_qwen_api" aiProviderDefaultModel(payload.provider);
? "qwen3.5-plus"
: payload.provider === "openai_api"
? "gpt-5.4"
: undefined;
const next: AiAccount = normalizeAiAccount({ const next: AiAccount = normalizeAiAccount({
accountId, accountId,
label: payload.label.trim() || aiRoleLabel(payload.role), label: payload.label.trim() || aiRoleLabel(payload.role),
@@ -5551,6 +5677,12 @@ export async function saveAiAccount(payload: {
nodeId: payload.nodeId?.trim() || undefined, nodeId: payload.nodeId?.trim() || undefined,
nodeLabel: payload.nodeLabel?.trim() || undefined, nodeLabel: payload.nodeLabel?.trim() || undefined,
model: payload.model?.trim() || defaultModel, model: payload.model?.trim() || defaultModel,
apiBaseUrl:
isApiKeyProvider(payload.provider)
? normalizeApiBaseUrl(payload.apiBaseUrl) ??
(!providerChanged ? existing?.apiBaseUrl : undefined) ??
aiProviderDefaultApiBaseUrl(payload.provider)
: undefined,
apiKey: apiKey:
isApiKeyProvider(payload.provider) isApiKeyProvider(payload.provider)
? payload.apiKey?.trim() ? payload.apiKey?.trim()
@@ -5593,7 +5725,19 @@ export async function saveAiAccount(payload: {
} }
if (payload.setActive ?? (!existing && next.role === "primary")) { if (payload.setActive ?? (!existing && next.role === "primary")) {
setActiveAiAccountInState(state, next.accountId, existing ? "手动更新 AI 账号配置" : "新增 AI 账号并设为当前主控"); if (!aiAccountCanGenerate(next)) {
next.isActive = false;
} else {
setActiveAiAccountInState(state, next.accountId, existing ? "手动更新 AI 账号配置" : "新增 AI 账号并设为当前主控");
}
} else if (next.isActive && !aiAccountCanGenerate(next)) {
next.isActive = false;
const fallback = sortAiAccounts(state.aiAccounts).find((item) =>
item.accountId !== next.accountId && aiAccountCanGenerate(item),
);
if (fallback) {
setActiveAiAccountInState(state, fallback.accountId, `当前主控 ${next.label} 暂不可用,自动切换`);
}
} }
return buildAiAccountSummary(next); return buildAiAccountSummary(next);
@@ -5643,6 +5787,13 @@ export async function activateAiAccount(accountId: string, reason: string) {
return result; return result;
} }
const result = await mutateState((state) => { const result = await mutateState((state) => {
const target = state.aiAccounts.find((item) => item.accountId === accountId);
if (!target) {
throw new Error("AI_ACCOUNT_NOT_FOUND");
}
if (!aiAccountCanGenerate(target)) {
throw new Error("AI_ACCOUNT_NOT_READY_FOR_ACTIVATION");
}
setActiveAiAccountInState(state, accountId, reason); setActiveAiAccountInState(state, accountId, reason);
return { return {
activeIdentity: getMasterIdentitySummaryFromState(state), activeIdentity: getMasterIdentitySummaryFromState(state),
@@ -5692,6 +5843,10 @@ export async function updateAiAccountHealth(params: {
export async function getMasterAgentRuntimeAccount() { export async function getMasterAgentRuntimeAccount() {
const state = await readState(); const state = await readState();
return resolveMasterAgentRuntimeAccountFromState(state);
}
export function resolveMasterAgentRuntimeAccountFromState(state: BossState) {
const resolved = resolveActiveAiAccount(state); const resolved = resolveActiveAiAccount(state);
if (!resolved.account) { if (!resolved.account) {
return null; return null;
@@ -5734,6 +5889,7 @@ export async function queueMasterAgentTask(payload: {
deviceImportCandidateFolderName?: string; deviceImportCandidateFolderName?: string;
projectUnderstandingTargetProjectId?: string; projectUnderstandingTargetProjectId?: string;
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply"; projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
relayViaMasterAgent?: boolean;
}) { }) {
const task = await mutateState((state) => { const task = await mutateState((state) => {
const task: MasterAgentTask = { const task: MasterAgentTask = {
@@ -5767,6 +5923,7 @@ export async function queueMasterAgentTask(payload: {
deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName, deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName,
projectUnderstandingTargetProjectId: payload.projectUnderstandingTargetProjectId, projectUnderstandingTargetProjectId: payload.projectUnderstandingTargetProjectId,
projectUnderstandingReason: payload.projectUnderstandingReason, projectUnderstandingReason: payload.projectUnderstandingReason,
relayViaMasterAgent: payload.relayViaMasterAgent === true ? true : undefined,
status: "queued", status: "queued",
requestedAt: nowIso(), requestedAt: nowIso(),
}; };
@@ -6583,6 +6740,8 @@ export async function claimNextMasterAgentTask(deviceId: string) {
if (isCliWriteTask(queued)) { if (isCliWriteTask(queued)) {
const scope = resolveProjectConflictScopeForTask(snapshot, queued); const scope = resolveProjectConflictScopeForTask(snapshot, queued);
const externalActivityAt = scope?.project?.threadMeta.lastObservedCodexActivityAt; const externalActivityAt = scope?.project?.threadMeta.lastObservedCodexActivityAt;
const claimActivityAt = nowIso();
let conflictActivityAt = externalActivityAt;
if (scope) { if (scope) {
const existingPolicy = findProjectExecutionPolicyInState(snapshot, scope); const existingPolicy = findProjectExecutionPolicyInState(snapshot, scope);
const fallbackPolicy = const fallbackPolicy =
@@ -6590,18 +6749,28 @@ export async function claimNextMasterAgentTask(deviceId: string) {
snapshot.projectExecutionPolicies.find( snapshot.projectExecutionPolicies.find(
(policy) => policy.deviceId === deviceId && policy.projectId === scope.projectId, (policy) => policy.deviceId === deviceId && policy.projectId === scope.projectId,
); );
if (fallbackPolicy?.conflictState === "blocked" && fallbackPolicy.allowPolicy === "forbid") { const policyActivityAt = fallbackPolicy?.recentExternalActivityAt;
conflictActivityAt = policyActivityAt ?? externalActivityAt;
if (
fallbackPolicy?.conflictState === "blocked" &&
fallbackPolicy.allowPolicy === "forbid" &&
(!policyActivityAt ||
hasRecentThreadConversationExternalActivity({
activityAt: claimActivityAt,
externalActivityAt: policyActivityAt,
}))
) {
return null; return null;
} }
} }
if (scope && externalActivityAt) { if (scope && conflictActivityAt) {
const conflict = await detectProjectExecutionConflict({ const conflict = await detectProjectExecutionConflict({
deviceId, deviceId,
folderKey: scope.folderKey, folderKey: scope.folderKey,
projectId: scope.projectId, projectId: scope.projectId,
executionMode: "cli", executionMode: "cli",
activityAt: nowIso(), activityAt: claimActivityAt,
externalActivityAt, externalActivityAt: conflictActivityAt,
}); });
if (conflict.blocked) { if (conflict.blocked) {
return null; return null;
@@ -6847,42 +7016,51 @@ export async function completeMasterAgentTask(payload: {
applyProjectUnderstandingSnapshotInState(state, { applyProjectUnderstandingSnapshotInState(state, {
projectId: task.projectUnderstandingTargetProjectId, projectId: task.projectUnderstandingTargetProjectId,
account: task.requestedByAccount, account: task.requestedByAccount,
snapshot: understanding, snapshot: understanding.snapshot,
sourceMessageId: task.requestMessageId, sourceMessageId: task.requestMessageId,
sourceKind: "thread_sync", sourceKind: "thread_sync",
}); });
const versionRecordAppended = appendProjectVersionFromUnderstandingSyncInState(state, {
projectId: task.projectUnderstandingTargetProjectId,
versionRecord: understanding.versionRecord,
updatedAt: understanding.snapshot.updatedAt,
});
if ( if (
targetProject && targetProject &&
shouldAnnounceProjectUnderstandingUpdate(previousUnderstanding, understanding) shouldAnnounceProjectUnderstandingUpdate(previousUnderstanding, understanding.snapshot)
) { ) {
const projectDisplayName = const projectDisplayName =
targetProject.threadMeta.threadDisplayName?.trim() || targetProject.name; targetProject.threadMeta.threadDisplayName?.trim() || targetProject.name;
pushProjectLedgerMessage(state, "master-agent", { pushProjectLedgerMessage(state, "master-agent", {
sender: "master", sender: "master",
senderLabel: "主 Agent", senderLabel: "主 Agent",
body: buildProjectUnderstandingUpdateDigest(projectDisplayName, understanding), body: buildProjectUnderstandingUpdateDigest(projectDisplayName, understanding.snapshot),
kind: "system_notice", kind: "system_notice",
}); });
if ( if (
understanding.recommendedNextStep?.trim() && understanding.snapshot.recommendedNextStep?.trim() &&
previousUnderstanding?.recommendedNextStep !== understanding.recommendedNextStep previousUnderstanding?.recommendedNextStep !== understanding.snapshot.recommendedNextStep
) { ) {
pushProjectLedgerMessage(state, "master-agent", { pushProjectLedgerMessage(state, "master-agent", {
sender: "master", sender: "master",
senderLabel: "主 Agent", senderLabel: "主 Agent",
body: buildProjectUnderstandingNextStepNotice(projectDisplayName, understanding), body: buildProjectUnderstandingNextStepNotice(projectDisplayName, understanding.snapshot),
kind: "system_notice", kind: "system_notice",
}); });
pushProjectLedgerMessage(state, "master-agent", { pushProjectLedgerMessage(state, "master-agent", {
sender: "master", sender: "master",
senderLabel: "主 Agent", senderLabel: "主 Agent",
body: buildProjectUnderstandingCollaborationNotice(projectDisplayName, understanding), body: buildProjectUnderstandingCollaborationNotice(projectDisplayName, understanding.snapshot),
kind: "system_notice", kind: "system_notice",
}); });
} }
publishBossEvent("project.messages.updated", { projectId: "master-agent" }); publishBossEvent("project.messages.updated", { projectId: "master-agent" });
publishBossEvent("conversation.updated", { projectId: "master-agent" }); publishBossEvent("conversation.updated", { projectId: "master-agent" });
} }
if (versionRecordAppended) {
publishBossEvent("conversation.updated", { projectId: task.projectUnderstandingTargetProjectId, note: "project_versions.updated" });
}
publishBossEvent("conversation.updated", { projectId: task.projectUnderstandingTargetProjectId, note: "project_goals.updated" });
publishBossEvent("conversation.updated", { projectId: task.projectUnderstandingTargetProjectId }); publishBossEvent("conversation.updated", { projectId: task.projectUnderstandingTargetProjectId });
} }
} else if (isThreadConversationReply) { } else if (isThreadConversationReply) {
@@ -6891,12 +7069,15 @@ export async function completeMasterAgentTask(payload: {
); );
const device = state.devices.find((item) => item.id === payload.deviceId); const device = state.devices.find((item) => item.id === payload.deviceId);
pushProjectLedgerMessage(state, threadProject?.id ?? task.projectId, { pushProjectLedgerMessage(state, threadProject?.id ?? task.projectId, {
sender: "device", sender: task.relayViaMasterAgent ? "master" : "device",
senderLabel: senderLabel: task.relayViaMasterAgent
task.targetThreadDisplayName?.trim() || ? task.accountLabel
threadProject?.threadMeta.threadDisplayName || ? `主 Agent · ${task.accountLabel}`
device?.name || : "主 Agent"
"线程", : task.targetThreadDisplayName?.trim() ||
threadProject?.threadMeta.threadDisplayName ||
device?.name ||
"线程",
body: task.replyBody, body: task.replyBody,
kind: "text", kind: "text",
}); });
@@ -6922,12 +7103,16 @@ export async function completeMasterAgentTask(payload: {
pushProjectLedgerMessage(state, task.projectId, { pushProjectLedgerMessage(state, task.projectId, {
sender: "ops", sender: "ops",
senderLabel: isThreadConversationReply senderLabel: isThreadConversationReply
? "线程执行失败" ? task.relayViaMasterAgent
? "主 Agent Relay"
: "线程执行失败"
: task.accountLabel : task.accountLabel
? `主 Agent Relay · ${task.accountLabel}` ? `主 Agent Relay · ${task.accountLabel}`
: "主 Agent Relay", : "主 Agent Relay",
body: isThreadConversationReply body: isThreadConversationReply
? `${task.targetThreadDisplayName ?? "当前线程"} 执行失败:${buildFriendlyThreadExecutionError(task.errorMessage)}` ? task.relayViaMasterAgent
? `主 Agent 转述失败:${task.targetThreadDisplayName ?? "当前线程"} 暂时无法返回结果,${buildFriendlyThreadExecutionError(task.errorMessage)}`
: `${task.targetThreadDisplayName ?? "当前线程"} 执行失败:${buildFriendlyThreadExecutionError(task.errorMessage)}`
: `Master Codex Node 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`, : `Master Codex Node 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`,
kind: "text", kind: "text",
}); });
@@ -7408,20 +7593,36 @@ export async function detectProjectExecutionConflict(input: {
const existingPolicy = findProjectExecutionPolicyInState(state, scope); const existingPolicy = findProjectExecutionPolicyInState(state, scope);
const hasConflict = const hasConflict =
input.executionMode === "cli" && input.executionMode === "cli" &&
Boolean(input.externalActivityAt) && hasRecentThreadConversationExternalActivity({
input.externalActivityAt! <= input.activityAt; activityAt: input.activityAt,
externalActivityAt: input.externalActivityAt,
});
if (!hasConflict) { if (!hasConflict) {
const clearedPolicy = existingPolicy
? upsertProjectExecutionPolicyInState(state, {
...existingPolicy,
...scope,
allowPolicy: existingPolicy.allowPolicy ?? "forbid",
conflictState: "none",
activeCliExecution: false,
recentExternalActivityAt: undefined,
updatedAt: nowIso(),
})
: null;
result = { result = {
blocked: false, blocked: false,
policy: normalizeProjectExecutionPolicy({ policy: normalizeProjectExecutionPolicy(
...existingPolicy, clearedPolicy ?? {
...scope, ...existingPolicy,
allowPolicy: existingPolicy?.allowPolicy ?? "forbid", ...scope,
conflictState: existingPolicy?.conflictState ?? "none", allowPolicy: existingPolicy?.allowPolicy ?? "forbid",
activeCliExecution: false, conflictState: "none",
updatedAt: existingPolicy?.updatedAt ?? nowIso(), activeCliExecution: false,
}), recentExternalActivityAt: undefined,
updatedAt: existingPolicy?.updatedAt ?? nowIso(),
},
),
}; };
return; return;
} }
@@ -8034,7 +8235,7 @@ export async function getLatestDeviceImportDraft(deviceId: string) {
function parseStructuredProjectUnderstandingReply( function parseStructuredProjectUnderstandingReply(
task: Pick<MasterAgentTask, "replyBody" | "taskId" | "completedAt" | "requestedAt">, task: Pick<MasterAgentTask, "replyBody" | "taskId" | "completedAt" | "requestedAt">,
): ProjectUnderstandingSnapshot | null { ): { snapshot: ProjectUnderstandingSnapshot; versionRecord: string } | null {
const replyBody = task.replyBody?.trim(); const replyBody = task.replyBody?.trim();
if (!replyBody) { if (!replyBody) {
return null; return null;
@@ -8042,15 +8243,7 @@ function parseStructuredProjectUnderstandingReply(
const fencedMatch = replyBody.match(/```(?:json)?\s*([\s\S]*?)```/i); const fencedMatch = replyBody.match(/```(?:json)?\s*([\s\S]*?)```/i);
const jsonCandidate = fencedMatch?.[1]?.trim() ?? replyBody; const jsonCandidate = fencedMatch?.[1]?.trim() ?? replyBody;
let parsed: let parsed: ProjectUnderstandingSyncReply | null = null;
| {
projectGoal?: string;
currentProgress?: string;
technicalArchitecture?: string;
currentBlockers?: string;
recommendedNextStep?: string;
}
| null = null;
try { try {
parsed = JSON.parse(jsonCandidate); parsed = JSON.parse(jsonCandidate);
} catch { } catch {
@@ -8062,22 +8255,60 @@ function parseStructuredProjectUnderstandingReply(
const technicalArchitecture = parsed?.technicalArchitecture?.trim() ?? ""; const technicalArchitecture = parsed?.technicalArchitecture?.trim() ?? "";
const currentBlockers = parsed?.currentBlockers?.trim() ?? ""; const currentBlockers = parsed?.currentBlockers?.trim() ?? "";
const recommendedNextStep = parsed?.recommendedNextStep?.trim() ?? ""; const recommendedNextStep = parsed?.recommendedNextStep?.trim() ?? "";
if (!projectGoal && !currentProgress && !technicalArchitecture && !currentBlockers && !recommendedNextStep) { const versionRecord = parsed?.versionRecord?.trim() ?? "";
if (
!projectGoal &&
!currentProgress &&
!technicalArchitecture &&
!currentBlockers &&
!recommendedNextStep &&
!versionRecord
) {
return null; return null;
} }
return { return {
projectGoal, snapshot: {
currentProgress, projectGoal,
technicalArchitecture, currentProgress,
currentBlockers, technicalArchitecture,
recommendedNextStep, currentBlockers,
sourceTaskId: task.taskId, recommendedNextStep,
updatedAt: task.completedAt ?? task.requestedAt, sourceTaskId: task.taskId,
sourceKind: "thread_sync", updatedAt: task.completedAt ?? task.requestedAt,
sourceKind: "thread_sync",
},
versionRecord,
}; };
} }
function appendProjectVersionFromUnderstandingSyncInState(
state: BossState,
input: {
projectId: string;
versionRecord: string;
updatedAt: string;
},
) {
const versionRecord = input.versionRecord.trim();
if (!versionRecord) {
return false;
}
const project = state.projects.find((item) => item.id === input.projectId);
if (!project) {
return false;
}
if (project.versions.some((entry) => entry.summary === versionRecord)) {
return false;
}
project.versions.unshift({
version: `同步更新 ${input.updatedAt.slice(0, 10)}`,
summary: versionRecord,
createdAt: input.updatedAt,
});
return true;
}
function applyProjectUnderstandingSnapshotInState( function applyProjectUnderstandingSnapshotInState(
state: BossState, state: BossState,
input: { input: {
@@ -8259,18 +8490,18 @@ function shouldQueueProjectUnderstandingSync(
state: BossState, state: BossState,
reason: "heartbeat_activity" | "thread_reply" = "heartbeat_activity", reason: "heartbeat_activity" | "thread_reply" = "heartbeat_activity",
) { ) {
// 主 Agent 自动向线程发隐藏理解对话当前整体关闭。
// 保留现有数据模型,后续如果需要恢复,可在明确产品决策后重新开启。
void project;
void observedActivityAt;
void state;
void reason;
return false;
/*
if (!isDispatchableThreadProject(project)) { if (!isDispatchableThreadProject(project)) {
return false; return false;
} }
const takeoverControls = applyDerivedTakeoverControls(
state,
project.id,
state.user.account,
resolveStoredProjectAgentControls(state, project.id, state.user.account),
);
if (takeoverControls?.effectiveTakeoverEnabled !== true) {
return false;
}
const observedTs = Date.parse(observedActivityAt); const observedTs = Date.parse(observedActivityAt);
if (!Number.isFinite(observedTs)) { if (!Number.isFinite(observedTs)) {
return false; return false;
@@ -8314,7 +8545,6 @@ function shouldQueueProjectUnderstandingSync(
task.projectUnderstandingTargetProjectId === project.id && task.projectUnderstandingTargetProjectId === project.id &&
(task.status === "queued" || task.status === "running"), (task.status === "queued" || task.status === "running"),
); );
*/
} }
function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbeat_activity" | "thread_reply") { function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbeat_activity" | "thread_reply") {
@@ -8325,14 +8555,19 @@ function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbea
`文件夹:${project.threadMeta.folderName}`, `文件夹:${project.threadMeta.folderName}`,
`同步原因:${reason === "heartbeat_activity" ? "检测到线程有新活动" : "线程刚刚产生了新的执行结果"}`, `同步原因:${reason === "heartbeat_activity" ? "检测到线程有新活动" : "线程刚刚产生了新的执行结果"}`,
"", "",
"先基于当前项目本地可见的开发文档和实际代码进行汇总,再回答。",
"优先检查 README、docs、架构文档、版本记录和最近改动的关键代码文件不要只依赖当前对话残留上下文。",
"如果文档与代码不一致,以当前代码和最新开发文档为准。",
"",
"只输出 JSON不要输出解释性文字或 Markdown。", "只输出 JSON不要输出解释性文字或 Markdown。",
"JSON 结构固定为:", "JSON 结构固定为:",
'{ "projectGoal": "一句中文目标", "currentProgress": "一句中文进度", "technicalArchitecture": "一句中文架构说明", "currentBlockers": "一句中文阻塞说明", "recommendedNextStep": "一句中文建议动作" }', '{ "projectGoal": "一句中文目标", "currentProgress": "一句中文进度", "technicalArchitecture": "一句中文架构说明", "currentBlockers": "一句中文阻塞说明", "recommendedNextStep": "一句中文建议动作", "versionRecord": "一句中文版本记录摘要" }',
"", "",
"要求:", "要求:",
"1. 只写当前项目最重要、对主 Agent 接手有帮助的事实。", "1. 只写当前项目最重要、对主 Agent 接手有帮助的事实。",
"2. 不要重复内部字段、线程编号、目录路径、设备 ID。", "2. 不要重复内部字段、线程编号、目录路径、设备 ID。",
"3. 如果某个字段暂时不清楚,填空字符串。", "3. 如果某个字段暂时不清楚,填空字符串。",
"4. versionRecord 只写本次同步最值得写入版本记录的一条变化;如果没有,填空字符串。",
].join("\n"); ].join("\n");
} }
@@ -8340,10 +8575,25 @@ async function queueProjectUnderstandingSyncTask(input: {
projectId: string; projectId: string;
observedActivityAt: string; observedActivityAt: string;
reason: "heartbeat_activity" | "thread_reply"; reason: "heartbeat_activity" | "thread_reply";
}) { }, options?: { force?: boolean }) {
const state = await readState(); const state = await readState();
const project = state.projects.find((item) => item.id === input.projectId); const project = state.projects.find((item) => item.id === input.projectId);
if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state, input.reason)) { if (!project) {
return null;
}
const existingTask = state.masterAgentTasks.find(
(task) =>
task.projectId === "master-agent" &&
task.projectUnderstandingTargetProjectId === project.id &&
(task.status === "queued" || task.status === "running"),
);
if (existingTask) {
return existingTask;
}
if (
options?.force !== true &&
!shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state, input.reason)
) {
return null; return null;
} }
const requestedByAccount = state.user.account || project.deviceIds[0] || "17600003315"; const requestedByAccount = state.user.account || project.deviceIds[0] || "17600003315";
@@ -8379,6 +8629,14 @@ async function queueProjectUnderstandingSyncTask(input: {
return task; return task;
} }
export async function forceProjectUnderstandingSyncTask(input: {
projectId: string;
observedActivityAt: string;
reason: "heartbeat_activity" | "thread_reply";
}) {
return queueProjectUnderstandingSyncTask(input, { force: true });
}
export async function previewDeviceImportResolution(input: { deviceId: string }) { export async function previewDeviceImportResolution(input: { deviceId: string }) {
const state = await readState(); const state = await readState();
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId); const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
@@ -9212,107 +9470,161 @@ function buildAutoGroupChatName(memberProjects: Project[]) {
return `${titles[0]}${titles[1]}${titles.length}个线程`; return `${titles[0]}${titles[1]}${titles.length}个线程`;
} }
export async function appendProjectMessage(payload: { type AppendProjectMessagePayload = {
projectId: string; projectId: string;
sender?: MessageSender; sender?: MessageSender;
senderLabel?: string; senderLabel?: string;
body?: string; body?: string;
kind?: MessageKind; kind?: MessageKind;
attachments?: MessageAttachment[]; attachments?: MessageAttachment[];
};
function appendProjectMessageInState(
state: BossState,
project: Project,
payload: Omit<AppendProjectMessagePayload, "projectId">,
) {
const body = payload.body?.trim();
if (!body && payload.kind === "text") {
throw new Error("MESSAGE_BODY_REQUIRED");
}
if (payload.kind === "attachment" && (!payload.attachments || payload.attachments.length === 0)) {
throw new Error("ATTACHMENT_REQUIRED");
}
const firstAttachment = payload.attachments?.[0];
const message: Message = {
id: randomToken("msg"),
sender: payload.sender ?? "user",
senderLabel: payload.senderLabel ?? "你",
body:
body ??
(payload.kind === "attachment"
? buildAttachmentMessageBody(
firstAttachment ?? {
attachmentId: randomToken("att"),
fileName: "附件",
mimeType: "application/octet-stream",
fileSizeBytes: 0,
attachmentKind: "binary",
storageBackend: "server_file",
storagePath: "",
previewAvailable: false,
uploadedAt: nowIso(),
uploadedBy: payload.senderLabel ?? "你",
analysisState: "not_applicable",
},
)
: payload.kind === "voice_intent"
? "已提交语音转文字请求,等待主 Agent 记录语音摘要。"
: payload.kind === "image_intent"
? "已登记图片证据上传请求,等待对象存储通道接入。"
: payload.kind === "video_intent"
? "已登记视频证据上传请求,等待对象存储通道接入。"
: "已提交消息。"),
sentAt: nowIso(),
kind: payload.kind ?? "text",
attachments: payload.attachments?.map((attachment) => normalizeMessageAttachment(attachment)),
};
project.messages.push(message);
project.unreadCount = 0;
project.lastMessageAt = message.sentAt;
project.preview = message.body;
const shouldTrackThreadProgress =
payload.sender === "device" &&
(payload.kind ?? "text") === "text" &&
isDispatchableThreadProject(project) &&
Boolean(project.threadMeta.codexThreadRef?.trim());
if (shouldTrackThreadProgress) {
project.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp(
project.threadMeta.lastObservedCodexActivityAt,
message.sentAt,
) ?? message.sentAt;
appendThreadProgressEventInState(state, {
projectId: project.id,
threadId: project.threadMeta.threadId,
threadDisplayName: project.threadMeta.threadDisplayName,
deviceId: project.deviceIds[0] ?? project.id,
eventType: "progress_updated",
summary: summarizeThreadReplyBody(message.body),
phase: project.projectUnderstanding ? "增量同步" : "线程回复",
createdAt: message.sentAt,
sourceTaskId: message.id,
sourceMessageId: message.id,
});
}
return {
message,
shouldQueueUnderstandingSync:
shouldTrackThreadProgress &&
shouldQueueProjectUnderstandingSync(project, message.sentAt, state, "thread_reply"),
};
}
export async function appendProjectMessages(payload: {
projectId: string;
messages: Array<Omit<AppendProjectMessagePayload, "projectId">>;
}) { }) {
const result = await mutateState((state) => { const result = await mutateState((state) => {
const project = state.projects.find((item) => item.id === payload.projectId); const project = state.projects.find((item) => item.id === payload.projectId);
if (!project) throw new Error("PROJECT_NOT_FOUND"); if (!project) throw new Error("PROJECT_NOT_FOUND");
const body = payload.body?.trim(); const appended = payload.messages.map((messagePayload) =>
if (!body && payload.kind === "text") { appendProjectMessageInState(state, project, messagePayload),
throw new Error("MESSAGE_BODY_REQUIRED"); );
}
if (payload.kind === "attachment" && (!payload.attachments || payload.attachments.length === 0)) {
throw new Error("ATTACHMENT_REQUIRED");
}
const firstAttachment = payload.attachments?.[0];
const message: Message = {
id: randomToken("msg"),
sender: payload.sender ?? "user",
senderLabel: payload.senderLabel ?? "你",
body:
body ??
(payload.kind === "attachment"
? buildAttachmentMessageBody(
firstAttachment ?? {
attachmentId: randomToken("att"),
fileName: "附件",
mimeType: "application/octet-stream",
fileSizeBytes: 0,
attachmentKind: "binary",
storageBackend: "server_file",
storagePath: "",
previewAvailable: false,
uploadedAt: nowIso(),
uploadedBy: payload.senderLabel ?? "你",
analysisState: "not_applicable",
},
)
: payload.kind === "voice_intent"
? "已提交语音转文字请求,等待主 Agent 记录语音摘要。"
: payload.kind === "image_intent"
? "已登记图片证据上传请求,等待对象存储通道接入。"
: payload.kind === "video_intent"
? "已登记视频证据上传请求,等待对象存储通道接入。"
: "已提交消息。"),
sentAt: nowIso(),
kind: payload.kind ?? "text",
attachments: payload.attachments?.map((attachment) => normalizeMessageAttachment(attachment)),
};
project.messages.push(message);
project.unreadCount = 0;
project.lastMessageAt = message.sentAt;
project.preview = message.body;
const shouldTrackThreadProgress =
payload.sender === "device" &&
(payload.kind ?? "text") === "text" &&
isDispatchableThreadProject(project) &&
Boolean(project.threadMeta.codexThreadRef?.trim());
if (shouldTrackThreadProgress) {
project.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp(
project.threadMeta.lastObservedCodexActivityAt,
message.sentAt,
) ?? message.sentAt;
appendThreadProgressEventInState(state, {
projectId: project.id,
threadId: project.threadMeta.threadId,
threadDisplayName: project.threadMeta.threadDisplayName,
deviceId: project.deviceIds[0] ?? project.id,
eventType: "progress_updated",
summary: summarizeThreadReplyBody(message.body),
phase: project.projectUnderstanding ? "增量同步" : "线程回复",
createdAt: message.sentAt,
sourceTaskId: message.id,
sourceMessageId: message.id,
});
}
return { return {
message, messages: appended.map((item) => item.message),
shouldQueueUnderstandingSync: shouldQueueUnderstandingSync: appended.some((item) => item.shouldQueueUnderstandingSync),
shouldTrackThreadProgress &&
shouldQueueProjectUnderstandingSync(project, message.sentAt, state, "thread_reply"),
}; };
}); });
if (result.shouldQueueUnderstandingSync) { if (result.shouldQueueUnderstandingSync) {
await queueProjectUnderstandingSyncTask({ await queueProjectUnderstandingSyncTask({
projectId: payload.projectId, projectId: payload.projectId,
observedActivityAt: result.message.sentAt, observedActivityAt: result.messages.at(-1)?.sentAt ?? nowIso(),
reason: "thread_reply", reason: "thread_reply",
}); });
} }
publishBossEvent("project.messages.updated", { projectId: payload.projectId }); publishBossEvent("project.messages.updated", { projectId: payload.projectId });
publishBossEvent("conversation.updated", { projectId: payload.projectId }); publishBossEvent("conversation.updated", { projectId: payload.projectId });
return result.message; return result.messages;
}
export async function appendProjectMessage(payload: AppendProjectMessagePayload) {
const [message] = await appendProjectMessages({
projectId: payload.projectId,
messages: [
{
sender: payload.sender,
senderLabel: payload.senderLabel,
body: payload.body,
kind: payload.kind,
attachments: payload.attachments,
},
],
});
if (!message) {
throw new Error("MESSAGE_NOT_CREATED");
}
return message;
}
export async function requestProjectUnderstandingSyncForProject(input: {
projectId: string;
observedActivityAt?: string;
reason?: "heartbeat_activity" | "thread_reply";
}) {
return queueProjectUnderstandingSyncTask(
{
projectId: input.projectId,
observedActivityAt: input.observedActivityAt ?? nowIso(),
reason: input.reason ?? "thread_reply",
},
{ force: true },
);
} }
export async function appendAttachmentMessage(payload: { export async function appendAttachmentMessage(payload: {

File diff suppressed because it is too large Load Diff

108
src/lib/chat-markdown.ts Normal file
View File

@@ -0,0 +1,108 @@
export type ChatMarkdownBlock =
| { kind: "heading"; text: string; level: 1 | 2 | 3 }
| { kind: "bullet"; text: string }
| { kind: "ordered"; text: string; order: string }
| { kind: "quote"; text: string }
| { kind: "code"; text: string }
| { kind: "label"; label: string; text: string }
| { kind: "paragraph"; text: string };
const headingPattern = /^(#{1,3})\s+(.+)$/;
const bulletPattern = /^[-*]\s+(.+)$/;
const orderedPattern = /^(\d+)\.\s+(.+)$/;
const labelPattern = /^([^:\n]{1,24})[:]\s*(.+)$/;
export function parseChatMarkdown(markdown: string): ChatMarkdownBlock[] {
const normalized = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const blocks: ChatMarkdownBlock[] = [];
const paragraphLines: string[] = [];
const codeLines: string[] = [];
let inCodeFence = false;
const flushParagraph = () => {
const text = paragraphLines.join("\n").trim();
paragraphLines.length = 0;
if (text) {
blocks.push({ kind: "paragraph", text });
}
};
const flushCode = () => {
const text = codeLines.join("\n").trimEnd();
codeLines.length = 0;
if (text) {
blocks.push({ kind: "code", text });
}
};
for (const line of normalized.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("```")) {
if (inCodeFence) {
flushCode();
} else {
flushParagraph();
}
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence) {
codeLines.push(line);
continue;
}
if (!trimmed) {
flushParagraph();
continue;
}
const heading = headingPattern.exec(line);
if (heading) {
flushParagraph();
blocks.push({
kind: "heading",
level: Math.min(heading[1]!.length, 3) as 1 | 2 | 3,
text: heading[2]!.trim(),
});
continue;
}
const bullet = bulletPattern.exec(trimmed);
if (bullet) {
flushParagraph();
blocks.push({ kind: "bullet", text: bullet[1]!.trim() });
continue;
}
const ordered = orderedPattern.exec(trimmed);
if (ordered) {
flushParagraph();
blocks.push({ kind: "ordered", order: `${ordered[1]}.`, text: ordered[2]!.trim() });
continue;
}
if (trimmed.startsWith(">")) {
flushParagraph();
blocks.push({ kind: "quote", text: trimmed.slice(1).trim() || "引用" });
continue;
}
const label = labelPattern.exec(trimmed);
if (label) {
flushParagraph();
blocks.push({ kind: "label", label: label[1]!.trim(), text: label[2]!.trim() });
continue;
}
paragraphLines.push(line);
}
if (inCodeFence) {
flushCode();
}
flushParagraph();
return blocks;
}

View File

@@ -0,0 +1,13 @@
const DEFAULT_MASTER_AGENT_MODEL_OPTIONS = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"] as const;
export function getMasterAgentModelOptions(currentModelOverride?: string | null) {
const current = currentModelOverride?.trim();
if (!current) {
return [...DEFAULT_MASTER_AGENT_MODEL_OPTIONS];
}
if (DEFAULT_MASTER_AGENT_MODEL_OPTIONS.includes(current as (typeof DEFAULT_MASTER_AGENT_MODEL_OPTIONS)[number])) {
return [...DEFAULT_MASTER_AGENT_MODEL_OPTIONS];
}
return [current, ...DEFAULT_MASTER_AGENT_MODEL_OPTIONS];
}

View File

@@ -5,6 +5,38 @@ export type ThreadConversationExecutionConflictReason =
| "preferred_gui_mode" | "preferred_gui_mode"
| "project_conflict_forbid"; | "project_conflict_forbid";
export const THREAD_CONVERSATION_EXTERNAL_ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
function parseTimestampMs(value?: string | null) {
if (!value?.trim()) {
return null;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
export function hasRecentThreadConversationExternalActivity(input: {
activityAt: string;
externalActivityAt?: string | null;
windowMs?: number;
}) {
const activityTs = parseTimestampMs(input.activityAt);
const externalTs = parseTimestampMs(input.externalActivityAt);
if (activityTs === null || externalTs === null) {
return false;
}
if (externalTs > activityTs) {
return false;
}
const windowMs =
Number.isFinite(input.windowMs) && Number(input.windowMs) >= 0
? Number(input.windowMs)
: THREAD_CONVERSATION_EXTERNAL_ACTIVITY_WINDOW_MS;
return activityTs - externalTs <= windowMs;
}
export interface ThreadConversationExecutionConflict { export interface ThreadConversationExecutionConflict {
projectId: string; projectId: string;
projectName: string; projectName: string;

View File

@@ -0,0 +1,292 @@
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 createAccountRoute: (typeof import("../src/app/api/v1/accounts/route"))["POST"];
let updateAccountRoute: (typeof import("../src/app/api/v1/accounts/[accountId]/route"))["PATCH"];
let validateDraftAccountRoute: (typeof import("../src/app/api/v1/accounts/validate-draft/route"))["POST"];
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let AUTH_SESSION_COOKIE = "";
async function setup() {
if (runtimeRoot) {
return;
}
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-ai-account-routes-"));
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
const [accountsModule, accountDetailModule, validateDraftModule, dataModule, authModule] = await Promise.all([
import("../src/app/api/v1/accounts/route.ts"),
import("../src/app/api/v1/accounts/[accountId]/route.ts"),
import("../src/app/api/v1/accounts/validate-draft/route.ts"),
import("../src/lib/boss-data.ts"),
import("../src/lib/boss-auth.ts"),
]);
createAccountRoute = accountsModule.POST;
updateAccountRoute = accountDetailModule.PATCH;
validateDraftAccountRoute = validateDraftModule.POST;
createAuthSession = dataModule.createAuthSession;
AUTH_SESSION_COOKIE = authModule.AUTH_SESSION_COOKIE;
}
test.after(async () => {
if (runtimeRoot) {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
async function createAuthedJsonRequest(url: string, method: "POST" | "PATCH", body: Record<string, unknown>) {
const session = await createAuthSession({
account: "17600003315",
role: "highest_admin",
displayName: "Boss 超级管理员",
loginMethod: "password",
});
return new NextRequest(url, {
method,
headers: {
"content-type": "application/json",
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
},
body: JSON.stringify(body),
});
}
test("POST /api/v1/accounts accepts 环宇智擎 accounts", async () => {
await setup();
const response = await createAccountRoute(
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts", "POST", {
label: "主 GPT",
role: "primary",
provider: "hyzq_api",
displayName: "环宇智擎主链路",
model: "gpt-5.4-mini",
apiKey: "sk-hyzq-demo-123456",
enabled: true,
setActive: true,
}),
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
account: {
provider: string;
providerLabel: string;
apiBaseUrl?: string;
isActive: boolean;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.account.provider, "hyzq_api");
assert.equal(payload.account.providerLabel, "环宇智擎 API");
assert.equal(payload.account.apiBaseUrl, "https://api.hyzq2046.com/v1");
assert.equal(payload.account.isActive, true);
});
test("PATCH /api/v1/accounts/[accountId] accepts GLM accounts", async () => {
await setup();
const createResponse = await createAccountRoute(
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts", "POST", {
label: "备用 GPT",
role: "backup",
provider: "custom_api",
displayName: "临时备用链路",
model: "temp-model",
apiBaseUrl: "https://gateway.example.com/v1",
apiKey: "sk-temp-demo-123456",
enabled: true,
}),
);
const createPayload = (await createResponse.json()) as { account: { accountId: string } };
const response = await updateAccountRoute(
await createAuthedJsonRequest(
`http://127.0.0.1:3000/api/v1/accounts/${createPayload.account.accountId}`,
"PATCH",
{
label: "备用 GPT",
role: "backup",
provider: "glm_api",
displayName: "GLM 备用账号",
model: "glm-4.5",
apiKey: "sk-glm-demo-123456",
enabled: true,
},
),
{ params: Promise.resolve({ accountId: createPayload.account.accountId }) },
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
account: {
provider: string;
providerLabel: string;
apiBaseUrl?: string;
displayName: string;
};
};
assert.equal(payload.ok, true);
assert.equal(payload.account.provider, "glm_api");
assert.equal(payload.account.providerLabel, "GLM API");
assert.equal(payload.account.apiBaseUrl, "https://open.bigmodel.cn/api/paas/v4");
assert.equal(payload.account.displayName, "GLM 备用账号");
});
test("POST /api/v1/accounts/validate-draft probes API draft and returns available models", async () => {
await setup();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://api.hyzq2046.com/v1/responses") {
return new Response(JSON.stringify({ output_text: "连接正常" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-hyzq-draft-validate",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const response = await validateDraftAccountRoute(
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/validate-draft", "POST", {
provider: "hyzq_api",
apiKey: "sk-hyzq-demo-123456",
}),
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
status: string;
requestId?: string;
availableModels: string[];
};
assert.equal(payload.ok, true);
assert.equal(payload.status, "ready");
assert.equal(payload.requestId, "req-hyzq-draft-validate");
assert.deepEqual(payload.availableModels, ["gpt-5.4-mini", "gpt-5.4"]);
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/v1/accounts/validate-draft prefers provider returned models over static defaults", async () => {
await setup();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://api.openai.com/v1/responses") {
return new Response(JSON.stringify({ output_text: "连接正常" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-openai-draft-validate",
},
});
}
if (typeof input === "string" && input === "https://api.openai.com/v1/models") {
return new Response(JSON.stringify({
data: [
{ id: "gpt-5.4" },
{ id: "gpt-4.1" },
],
}), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const response = await validateDraftAccountRoute(
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/validate-draft", "POST", {
provider: "openai_api",
apiKey: "sk-openai-demo-123456",
}),
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
availableModels: string[];
};
assert.equal(payload.ok, true);
assert.deepEqual(payload.availableModels, ["gpt-5.4", "gpt-4.1"]);
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/v1/accounts/validate-draft falls back to compatible models for custom api", async () => {
await setup();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://gateway.example.com/v1/chat/completions") {
return new Response(JSON.stringify({
choices: [
{
message: {
content: "连接正常",
},
},
],
}), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-custom-draft-validate",
},
});
}
if (typeof input === "string" && input === "https://gateway.example.com/v1/models") {
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const response = await validateDraftAccountRoute(
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/validate-draft", "POST", {
provider: "custom_api",
apiKey: "sk-custom-demo-123456",
apiBaseUrl: "https://gateway.example.com/v1",
}),
);
assert.equal(response.status, 200);
const payload = (await response.json()) as {
ok: boolean;
message: string;
availableModels: string[];
};
assert.equal(payload.ok, true);
assert.match(payload.message, /兜底/);
assert.deepEqual(payload.availableModels, ["gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"]);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -118,3 +118,190 @@ test("validateAiAccountConnection probes aliyun qwen backup accounts through the
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
}); });
test("validateAiAccountConnection honors custom api base url overrides for OpenAI accounts", async () => {
await setup();
await saveAiAccount(Object.assign({
accountId: "openai-api-primary",
label: "主 GPT",
role: "primary" as const,
provider: "openai_api" as const,
displayName: "OpenAI 平台账号",
accountIdentifier: "openai-demo",
model: "gpt-5.4-mini",
apiKey: "sk-openai-demo-123456",
enabled: true,
}, {
apiBaseUrl: "https://gateway.example.com/openai/v1",
}));
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://gateway.example.com/openai/v1/responses") {
return new Response(JSON.stringify({ output_text: "连接正常" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-openai-custom-endpoint",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const result = await validateAiAccountConnection("openai-api-primary");
assert.equal(result.ok, true);
assert.equal(result.status, "ready");
assert.match(result.message, /连接正常/);
assert.equal(result.requestId, "req-openai-custom-endpoint");
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateAiAccountConnection uses the default 环宇智擎 endpoint for primary API accounts", async () => {
await setup();
await saveAiAccount({
accountId: "hyzq-primary",
label: "主 GPT",
role: "primary",
provider: "hyzq_api",
displayName: "环宇智擎主链路",
accountIdentifier: "hyzq-primary-demo",
model: "gpt-5.4-mini",
apiKey: "sk-hyzq-demo-123456",
enabled: true,
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://api.hyzq2046.com/v1/responses") {
return new Response(JSON.stringify({ output_text: "连接正常" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-hyzq-validate",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const result = await validateAiAccountConnection("hyzq-primary");
assert.equal(result.ok, true);
assert.equal(result.status, "ready");
assert.equal(result.requestId, "req-hyzq-validate");
assert.match(result.message, /连接正常/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateAiAccountConnection probes GLM accounts through chat completions", async () => {
await setup();
await saveAiAccount({
accountId: "glm-backup",
label: "备用 GPT",
role: "backup",
provider: "glm_api",
displayName: "GLM 备用账号",
accountIdentifier: "glm-demo",
model: "glm-4.5",
apiKey: "sk-glm-demo-123456",
enabled: true,
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://open.bigmodel.cn/api/paas/v4/chat/completions") {
return new Response(JSON.stringify({
choices: [
{
message: {
content: "连接正常",
},
},
],
}), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-glm-validate",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const result = await validateAiAccountConnection("glm-backup");
assert.equal(result.ok, true);
assert.equal(result.status, "ready");
assert.equal(result.requestId, "req-glm-validate");
assert.match(result.message, /连接正常/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("validateAiAccountConnection falls back to generic models for custom api accounts", async () => {
await setup();
await saveAiAccount({
accountId: "custom-backup",
label: "备用 GPT",
role: "backup",
provider: "custom_api",
displayName: "自定义兼容 API",
accountIdentifier: "custom-demo",
model: "gpt-5.4",
apiBaseUrl: "https://gateway.example.com/v1",
apiKey: "sk-custom-demo-123456",
enabled: true,
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input) => {
if (typeof input === "string" && input === "https://gateway.example.com/v1/chat/completions") {
return new Response(JSON.stringify({
choices: [
{
message: {
content: "连接正常",
},
},
],
}), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-custom-validate",
},
});
}
if (typeof input === "string" && input === "https://gateway.example.com/v1/models") {
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}
throw new Error(`unexpected fetch: ${String(input)}`);
}) as typeof fetch;
try {
const result = await validateAiAccountConnection("custom-backup");
assert.equal(result.ok, true);
assert.equal(result.status, "ready");
assert.match(result.message, /兜底/);
assert.deepEqual(result.availableModels, ["gpt-5.4", "gpt-5.4-mini", "gpt-5.1", "gpt-4.1"]);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -0,0 +1,52 @@
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";
import { parseChatMarkdown } from "../src/lib/chat-markdown";
const testsDir = path.dirname(fileURLToPath(import.meta.url));
async function readWorkspaceFile(relativePath: string) {
return readFile(path.join(testsDir, "..", relativePath), "utf8");
}
test("parseChatMarkdown recognizes section labels and preserves reading order", () => {
const blocks = parseChatMarkdown(
"项目目标:完成 Boss 真机回归\n当前进度已完成 UI 调整\n下一步推送到 Gitea",
);
assert.equal(blocks.length, 3);
assert.deepEqual(
blocks.map((block) => block.kind),
["label", "label", "label"],
);
assert.equal(blocks[0]?.label, "项目目标");
assert.equal(blocks[0]?.text, "完成 Boss 真机回归");
assert.equal(blocks[1]?.label, "当前进度");
assert.equal(blocks[2]?.label, "下一步");
});
test("parseChatMarkdown keeps bullets, ordered items, quotes, and fenced code distinct", () => {
const blocks = parseChatMarkdown(
"# 标题\n\n- 第一项\n1. 第二项\n> 引用\n```ts\nconst ok = true;\n```",
);
assert.deepEqual(
blocks.map((block) => block.kind),
["heading", "bullet", "ordered", "quote", "code"],
);
assert.equal(blocks[0]?.text, "标题");
assert.equal(blocks[1]?.text, "第一项");
assert.equal(blocks[2]?.order, "1.");
assert.equal(blocks[3]?.text, "引用");
assert.match(blocks[4]?.text ?? "", /const ok = true;/);
});
test("ChatBubble renders parsed markdown blocks instead of raw plain text bodies", async () => {
const source = await readWorkspaceFile("src/components/app-ui.tsx");
assert.match(source, /import \{ parseChatMarkdown(?:, type ChatMarkdownBlock)? \} from "@\/lib\/chat-markdown"/);
assert.match(source, /const blocks = parseChatMarkdown\(body\);/);
assert.match(source, /function ChatBubbleMarkdown/);
});

View File

@@ -216,6 +216,7 @@ test("heartbeat external activity on an active cli folder blocks the next claim
const project = await getCliProject(); const project = await getCliProject();
const folderKey = buildProjectFolderKey(project); const folderKey = buildProjectFolderKey(project);
const recentExternalActivityAt = new Date(Date.now() - 60_000).toISOString();
const firstTask = await queueMasterAgentTask({ const firstTask = await queueMasterAgentTask({
projectId: project.id, projectId: project.id,
@@ -252,7 +253,7 @@ test("heartbeat external activity on an active cli folder blocks the next claim
threadDisplayName: project.threadMeta.threadDisplayName, threadDisplayName: project.threadMeta.threadDisplayName,
codexFolderRef: project.threadMeta.codexFolderRef, codexFolderRef: project.threadMeta.codexFolderRef,
codexThreadRef: project.threadMeta.codexThreadRef, codexThreadRef: project.threadMeta.codexThreadRef,
lastActiveAt: "2026-04-06T11:05:00.000Z", lastActiveAt: recentExternalActivityAt,
suggestedImport: true, suggestedImport: true,
}, },
], ],
@@ -263,7 +264,7 @@ test("heartbeat external activity on an active cli folder blocks the next claim
assert.ok(policy, "expected heartbeat to persist a scoped conflict policy"); assert.ok(policy, "expected heartbeat to persist a scoped conflict policy");
assert.equal(policy?.activeCliExecution, true); assert.equal(policy?.activeCliExecution, true);
assert.equal(policy?.conflictState, "blocked"); assert.equal(policy?.conflictState, "blocked");
assert.equal(policy?.recentExternalActivityAt, "2026-04-06T11:05:00.000Z"); assert.equal(policy?.recentExternalActivityAt, recentExternalActivityAt);
const secondTask = await queueMasterAgentTask({ const secondTask = await queueMasterAgentTask({
projectId: project.id, projectId: project.id,
@@ -314,3 +315,49 @@ test("heartbeat external activity on an active cli folder blocks the next claim
assert.equal(policy?.activeCliExecution, false); assert.equal(policy?.activeCliExecution, false);
assert.equal(policy?.conflictState, "blocked"); assert.equal(policy?.conflictState, "blocked");
}); });
test("stale blocked policy does not keep queued conversation replies stuck forever", async () => {
await setup();
const project = await getCliProject();
const folderKey = buildProjectFolderKey(project);
const state = await readState();
state.projectExecutionPolicies = [
{
deviceId: "mac-studio",
folderKey,
projectId: project.id,
allowPolicy: "forbid",
conflictState: "blocked",
recentExternalActivityAt: "2026-04-06T09:30:00.000Z",
updatedAt: "2026-04-06T09:30:00.000Z",
},
];
await writeState(state);
const queuedTask = await queueMasterAgentTask({
projectId: project.id,
requestMessageId: "msg-stale-policy",
requestText: "继续推进这个线程",
executionPrompt: "请继续推进这个线程",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
deviceId: "mac-studio",
taskType: "conversation_reply",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
});
const claimed = await claimNextMasterAgentTask("mac-studio");
assert.equal(claimed?.taskId, queuedTask.taskId);
const nextState = await readState();
const policy = nextState.projectExecutionPolicies.find((item) => item.folderKey === folderKey);
assert.ok(policy, "expected stale scoped policy to remain in state");
assert.equal(policy?.conflictState, "none");
assert.equal(policy?.activeCliExecution, true);
assert.equal(policy?.recentExternalActivityAt, undefined);
});

View File

@@ -131,6 +131,28 @@ test("master-agent 会话可保存并读取模型与推理强度覆盖", async (
assert.equal(detail?.agentControls?.reasoningEffortOverride, "high"); assert.equal(detail?.agentControls?.reasoningEffortOverride, "high");
}); });
test("master-agent 会话可保存并读取快速反应与深度思考模型映射", async () => {
await setup();
await updateProjectAgentControls("master-agent", {
fastModelOverride: "gpt-4.1",
deepModelOverride: "gpt-5.1",
});
const controls = await getProjectAgentControls("master-agent");
assert.equal(controls?.fastModelOverride, "gpt-4.1");
assert.equal(controls?.deepModelOverride, "gpt-5.1");
const state = await readState();
const project = state.projects.find((item) => item.id === "master-agent");
assert.equal(project?.agentControls?.fastModelOverride, "gpt-4.1");
assert.equal(project?.agentControls?.deepModelOverride, "gpt-5.1");
const detail = getProjectDetailView(state, "master-agent");
assert.equal(detail?.agentControls?.fastModelOverride, "gpt-4.1");
assert.equal(detail?.agentControls?.deepModelOverride, "gpt-5.1");
});
test("master-agent 对话控制路由可读写并回显到项目详情", async () => { test("master-agent 对话控制路由可读写并回显到项目详情", async () => {
await setup(); await setup();
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-claw-agent-controls-")); const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-claw-agent-controls-"));
@@ -167,6 +189,8 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
body: JSON.stringify({ body: JSON.stringify({
modelOverride: "gpt-5.4", modelOverride: "gpt-5.4",
reasoningEffortOverride: "medium", reasoningEffortOverride: "medium",
fastModelOverride: "gpt-4.1",
deepModelOverride: "gpt-5.1",
backendOverride: "claw-runtime", backendOverride: "claw-runtime",
}), }),
}), }),
@@ -179,6 +203,8 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: { controls: {
modelOverride?: string; modelOverride?: string;
reasoningEffortOverride?: string; reasoningEffortOverride?: string;
fastModelOverride?: string;
deepModelOverride?: string;
backendOverride?: string; backendOverride?: string;
updatedAt: string; updatedAt: string;
} | null; } | null;
@@ -186,6 +212,8 @@ 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-4.1");
assert.equal(postPayload.controls?.deepModelOverride, "gpt-5.1");
assert.equal(postPayload.controls?.backendOverride, "claw-runtime"); assert.equal(postPayload.controls?.backendOverride, "claw-runtime");
const getResponse = await getAgentControlsRoute( const getResponse = await getAgentControlsRoute(
@@ -202,6 +230,8 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
controls: { controls: {
modelOverride?: string; modelOverride?: string;
reasoningEffortOverride?: string; reasoningEffortOverride?: string;
fastModelOverride?: string;
deepModelOverride?: string;
backendOverride?: string; backendOverride?: string;
updatedAt: string; updatedAt: string;
} | null; } | null;
@@ -209,6 +239,8 @@ 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-4.1");
assert.equal(getPayload.controls?.deepModelOverride, "gpt-5.1");
assert.equal(getPayload.controls?.backendOverride, "claw-runtime"); assert.equal(getPayload.controls?.backendOverride, "claw-runtime");
const projectResponse = await getProjectRoute( const projectResponse = await getProjectRoute(
@@ -225,6 +257,8 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
agentControls: { agentControls: {
modelOverride?: string; modelOverride?: string;
reasoningEffortOverride?: string; reasoningEffortOverride?: string;
fastModelOverride?: string;
deepModelOverride?: string;
backendOverride?: string; backendOverride?: string;
updatedAt: string; updatedAt: string;
} | null; } | null;
@@ -232,6 +266,8 @@ 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-4.1");
assert.equal(projectPayload.agentControls?.deepModelOverride, "gpt-5.1");
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;

View File

@@ -147,7 +147,7 @@ test("主 Agent 执行 prompt 会明确声明管理员全局提示词不可覆
await createUserMasterMemory({ await createUserMasterMemory({
account: "17600003315", account: "17600003315",
scope: "project", scope: "project",
projectId: "boss-console", projectId: "boss-main",
title: "boss 项目进度", title: "boss 项目进度",
content: "boss 项目当前按项目聚合加线程下钻展示。", content: "boss 项目当前按项目聚合加线程下钻展示。",
memoryType: "project_progress", memoryType: "project_progress",
@@ -175,7 +175,7 @@ test("主 Agent 执行 prompt 会明确声明管理员全局提示词不可覆
"管理员全局主提示词:\n系统级主提示词", "管理员全局主提示词:\n系统级主提示词",
"用户私有主提示词:\n用户私有主提示词", "用户私有主提示词:\n用户私有主提示词",
"当前对话附加提示词:\n当前对话提示词", "当前对话附加提示词:\n当前对话提示词",
"项目记忆:\n- [boss-console] boss 项目进度: boss 项目当前按项目聚合加线程下钻展示。", "项目记忆:\n- [boss-main] boss 项目进度: boss 项目当前按项目聚合加线程下钻展示。",
"当前消息:\n继续推进 boss 项目的会话归档逻辑", "当前消息:\n继续推进 boss 项目的会话归档逻辑",
].join("\n\n"), ].join("\n\n"),
); );

View File

@@ -11,6 +11,7 @@ let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"]; 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 createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
let appendProjectMessages: (typeof import("../src/lib/boss-data"))["appendProjectMessages"];
let AUTH_SESSION_COOKIE = ""; let AUTH_SESSION_COOKIE = "";
async function setup() { async function setup() {
@@ -33,6 +34,7 @@ async function setup() {
updateProjectAgentControls = data.updateProjectAgentControls; updateProjectAgentControls = data.updateProjectAgentControls;
readState = data.readState; readState = data.readState;
createAuthSession = data.createAuthSession; createAuthSession = data.createAuthSession;
appendProjectMessages = data.appendProjectMessages;
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
} }
@@ -77,6 +79,40 @@ test.beforeEach(async () => {
await mkdir(runtimeRoot, { recursive: true }); await mkdir(runtimeRoot, { recursive: true });
}); });
test("appendProjectMessages 可以一次写入用户消息和主 Agent 本地快反回复", async () => {
const messages = await appendProjectMessages({
projectId: "master-agent",
messages: [
{
senderLabel: "Boss 超级管理员",
body: "你现在是什么模型",
kind: "text",
},
{
sender: "master",
senderLabel: "主 Agent · gpt-5.4-mini",
body: "当前主 Agent 是快速反应模式。",
kind: "text",
},
],
});
assert.equal(messages.length, 2);
assert.equal(messages[0]?.sender, "user");
assert.equal(messages[1]?.sender, "master");
const state = await readState();
const masterProject = state.projects.find((project) => project.id === "master-agent");
assert.equal(
masterProject?.messages.find((message) => message.id === messages[0]?.id)?.body,
"你现在是什么模型",
);
assert.equal(
masterProject?.messages.find((message) => message.id === messages[1]?.id)?.body,
"当前主 Agent 是快速反应模式。",
);
});
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",
@@ -165,6 +201,478 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
} }
}); });
test("POST /api/v1/projects/master-agent/messages 在快速反应模式下会对简单问题走同步快路径", async () => {
await saveAiAccount({
accountId: "openai-master-agent-fast-sync",
label: "API 容灾",
role: "api_fallback",
provider: "openai_api",
displayName: "OpenAI API 快反测试",
model: "gpt-5.4",
apiKey: "sk-test-openai-fast-sync",
enabled: true,
setActive: true,
loginStatusNote: "用于 master-agent 快速反应同步路径测试。",
});
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-4.1",
reasoningEffortOverride: "low",
fastModelOverride: "gpt-4.1",
deepModelOverride: "gpt-5.4",
});
const fetchCalls: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body ?? null;
fetchCalls.push({ url: String(input), body });
return new Response(JSON.stringify({ output_text: "快速反应正常。" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-master-agent-fast-sync",
},
});
}) as typeof fetch;
try {
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;
masterReply?: { accountId?: string; requestId?: string; autoEscalated?: boolean } | null;
replyMessage?: { sender?: string; body?: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
assert.equal(payload.masterReply?.accountId, "openai-master-agent-fast-sync");
assert.equal(payload.masterReply?.requestId, "req-master-agent-fast-sync");
assert.equal(payload.masterReply?.autoEscalated, false);
assert.equal(payload.replyMessage?.sender, "master");
assert.match(payload.replyMessage?.body ?? "", /快速反应正常/);
const nextState = await readState();
const queuedTask = nextState.masterAgentTasks.find((item) => item.requestText === "请说:快速反应正常。");
assert.equal(queuedTask, undefined);
const masterProject = nextState.projects.find((project) => project.id === "master-agent");
const reply = masterProject?.messages.at(-1);
assert.ok(reply, "expected the sync reply to be written back immediately");
assert.match(reply?.body ?? "", /快速反应正常/);
assert.equal(fetchCalls.length, 1);
const requestBody = fetchCalls[0]?.body as {
model?: string;
reasoning?: { effort?: string };
};
assert.equal(requestBody?.model, "gpt-4.1");
assert.equal(requestBody?.reasoning?.effort, "low");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/v1/projects/master-agent/messages 对模型状态类问题会本地秒回且不调用模型", async () => {
await saveAiAccount({
accountId: "master-codex-primary-local-status",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "在线 Master Codex Node",
nodeId: "mac-studio",
nodeLabel: "Mac Studio",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "在线主节点。",
});
await saveAiAccount({
accountId: "hyzq-fast-local-status",
label: "环宇快反",
role: "backup",
provider: "hyzq_api",
displayName: "环宇智擎 API",
model: "gpt-5.4-mini",
apiKey: "hyzq-fast-local-status-key",
enabled: true,
setActive: false,
loginStatusNote: "环宇智擎快反账号。",
});
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-5.4-mini",
reasoningEffortOverride: "low",
fastModelOverride: "gpt-5.4-mini",
deepModelOverride: "gpt-5.4",
});
const originalFetch = globalThis.fetch;
let fetchCalled = false;
globalThis.fetch = (async () => {
fetchCalled = true;
throw new Error("model call should not happen for local status replies");
}) as typeof fetch;
try {
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;
masterReply?: { accountId?: string; requestId?: string; effectiveModel?: string } | null;
replyMessage?: { sender?: string; body?: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
assert.equal(payload.masterReply?.requestId, "local-fast-path");
assert.equal(payload.masterReply?.effectiveModel, "gpt-5.4-mini");
assert.equal(payload.replyMessage?.sender, "master");
assert.match(payload.replyMessage?.body ?? "", /gpt-5.4-mini/);
assert.equal(fetchCalled, false);
const nextState = await readState();
const reply = nextState.projects.find((project) => project.id === "master-agent")?.messages.at(-1);
assert.match(reply?.body ?? "", /gpt-5.4-mini/);
assert.match(reply?.body ?? "", /快速反应/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/v1/projects/master-agent/messages 对可用模型查询会本地秒回并返回模式配置", async () => {
await saveAiAccount({
accountId: "hyzq-fast-local-list",
label: "环宇快反",
role: "primary",
provider: "hyzq_api",
displayName: "环宇智擎 API",
model: "gpt-5.4-mini",
apiKey: "hyzq-fast-local-list-key",
enabled: true,
setActive: true,
loginStatusNote: "环宇智擎快反账号。",
});
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-5.4-mini",
reasoningEffortOverride: "low",
fastModelOverride: "gpt-5.4-mini",
deepModelOverride: "gpt-5.4",
});
const originalFetch = globalThis.fetch;
let fetchCalled = false;
globalThis.fetch = (async () => {
fetchCalled = true;
throw new Error("model call should not happen for local model listing replies");
}) as typeof fetch;
try {
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;
masterReply?: { requestId?: string } | null;
replyMessage?: { body?: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
assert.equal(payload.masterReply?.requestId, "local-fast-path");
assert.match(payload.replyMessage?.body ?? "", /gpt-5\.4-mini/);
assert.match(payload.replyMessage?.body ?? "", /gpt-5\.4/);
assert.match(payload.replyMessage?.body ?? "", /gpt-5\.1/);
assert.match(payload.replyMessage?.body ?? "", /gpt-4\.1/);
assert.equal(fetchCalled, false);
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/v1/projects/master-agent/messages 对深度思考切换请求会本地更新 controls 并秒回", async () => {
await saveAiAccount({
accountId: "hyzq-fast-local-switch",
label: "环宇快反",
role: "primary",
provider: "hyzq_api",
displayName: "环宇智擎 API",
model: "gpt-5.4-mini",
apiKey: "hyzq-fast-local-switch-key",
enabled: true,
setActive: true,
loginStatusNote: "环宇智擎快反账号。",
});
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-5.4-mini",
reasoningEffortOverride: "low",
fastModelOverride: "gpt-5.4-mini",
deepModelOverride: "gpt-5.4",
});
const originalFetch = globalThis.fetch;
let fetchCalled = false;
globalThis.fetch = (async () => {
fetchCalled = true;
throw new Error("model call should not happen for local mode switching replies");
}) as typeof fetch;
try {
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;
masterReply?: { requestId?: string; effectiveModel?: string; effectiveReasoningEffort?: string } | null;
replyMessage?: { body?: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
assert.equal(payload.masterReply?.requestId, "local-fast-path");
assert.equal(payload.masterReply?.effectiveModel, "gpt-5.4");
assert.equal(payload.masterReply?.effectiveReasoningEffort, "high");
assert.match(payload.replyMessage?.body ?? "", /深度思考/);
assert.match(payload.replyMessage?.body ?? "", /gpt-5\.4/);
assert.equal(fetchCalled, false);
const controls = await readState().then((state) =>
state.userProjectAgentControls.find(
(entry) => entry.projectId === "master-agent" && entry.account === "17600003315",
)?.controls,
);
assert.equal(controls?.modelOverride, "gpt-5.4");
assert.equal(controls?.reasoningEffortOverride, "high");
assert.equal(controls?.fastModelOverride, "gpt-5.4-mini");
assert.equal(controls?.deepModelOverride, "gpt-5.4");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/v1/projects/master-agent/messages 在主节点在线时也会优先用环宇智擎执行快速反应", async () => {
await saveAiAccount({
accountId: "master-codex-primary-online-fast",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "在线 Master Codex Node",
nodeId: "mac-studio",
nodeLabel: "Mac Studio",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "在线主节点。",
});
await saveAiAccount({
accountId: "hyzq-fast-backup",
label: "环宇快反",
role: "backup",
provider: "hyzq_api",
displayName: "环宇智擎 API",
model: "gpt-5.4-mini",
apiKey: "hyzq-fast-key",
enabled: true,
setActive: false,
loginStatusNote: "环宇智擎快反账号。",
});
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-5.4-mini",
reasoningEffortOverride: "low",
fastModelOverride: "gpt-5.4-mini",
deepModelOverride: "gpt-5.4",
});
const fetchCalls: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body ?? null;
fetchCalls.push({ url: String(input), body });
return new Response(JSON.stringify({ output_text: "环宇快反正常。" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-master-agent-hyzq-fast",
},
});
}) as typeof fetch;
try {
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;
masterReply?: { accountId?: string; requestId?: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task ?? null, null);
assert.equal(payload.masterReplyState, "completed");
assert.equal(payload.masterReply?.accountId, "hyzq-fast-backup");
assert.equal(payload.masterReply?.requestId, "req-master-agent-hyzq-fast");
const nextState = await readState();
const queuedTask = nextState.masterAgentTasks.find((item) => item.requestText === "请说:环宇快反正常。");
assert.equal(queuedTask, undefined);
const reply = nextState.projects.find((project) => project.id === "master-agent")?.messages.at(-1);
assert.match(reply?.body ?? "", /环宇快反正常/);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0]?.url, "https://api.hyzq2046.com/v1/responses");
const requestBody = fetchCalls[0]?.body as {
model?: string;
reasoning?: { effort?: string };
};
assert.equal(requestBody?.model, "gpt-5.4-mini");
assert.equal(requestBody?.reasoning?.effort, "low");
} finally {
globalThis.fetch = originalFetch;
}
});
test("POST /api/v1/projects/master-agent/messages 在主节点在线时复杂快反请求会自动升档到环宇智擎深度队列", async () => {
await saveAiAccount({
accountId: "master-codex-primary-online-deep",
label: "主 GPT",
role: "primary",
provider: "master_codex_node",
displayName: "在线 Master Codex Node",
nodeId: "mac-studio",
nodeLabel: "Mac Studio",
model: "gpt-5.4",
enabled: true,
setActive: true,
loginStatusNote: "在线主节点。",
});
await saveAiAccount({
accountId: "hyzq-deep-backup",
label: "环宇深思",
role: "backup",
provider: "hyzq_api",
displayName: "环宇智擎 API",
model: "gpt-5.4-mini",
apiKey: "hyzq-deep-key",
enabled: true,
setActive: false,
loginStatusNote: "环宇智擎深思账号。",
});
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-5.4-mini",
reasoningEffortOverride: "low",
fastModelOverride: "gpt-5.4-mini",
deepModelOverride: "gpt-5.4",
});
const fetchCalls: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body ?? null;
fetchCalls.push({ url: String(input), body });
return new Response(JSON.stringify({ output_text: "已经升档到环宇深度思考。" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-master-agent-hyzq-deep",
},
});
}) as typeof fetch;
try {
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; taskType: string; status: string } | null;
masterReplyState?: "queued" | "running" | "completed" | null;
masterReply?: { accountId?: string; autoEscalated?: boolean } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.masterReplyState, "queued");
assert.equal(payload.masterReply?.accountId, "hyzq-deep-backup");
assert.equal(payload.masterReply?.autoEscalated, true);
assert.equal(payload.task?.taskType, "conversation_reply");
await waitFor(async () => {
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
return task?.status === "completed";
});
const nextState = await readState();
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
assert.equal(task?.accountId, "hyzq-deep-backup");
assert.equal(task?.deviceId, "master-agent-openai");
assert.equal(task?.replyBody, "已经升档到环宇深度思考。");
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0]?.url, "https://api.hyzq2046.com/v1/responses");
const requestBody = fetchCalls[0]?.body as {
model?: string;
reasoning?: { effort?: string };
};
assert.equal(requestBody?.model, "gpt-5.4");
assert.equal(requestBody?.reasoning?.effort, "high");
} finally {
globalThis.fetch = originalFetch;
}
});
test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队列而不是挂到本机设备队列", async () => { test("master-agent enqueue 在主节点离线时会自动切到 OpenAI 后台队列而不是挂到本机设备队列", async () => {
await saveAiAccount({ await saveAiAccount({
accountId: "master-codex-primary-offline", accountId: "master-codex-primary-offline",

View File

@@ -0,0 +1,17 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getMasterAgentModelOptions } from "../src/lib/master-agent-model-options";
test("主 Agent 模型选项会明确暴露快反和深思模型", () => {
assert.deepEqual(
getMasterAgentModelOptions(),
["gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"],
);
});
test("主 Agent 模型选项会保留当前自定义模型", () => {
assert.deepEqual(
getMasterAgentModelOptions("gpt-4.1-mini"),
["gpt-4.1-mini", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"],
);
});

View File

@@ -9,6 +9,7 @@ let replyToMasterAgentUserMessage: (typeof import("../src/lib/boss-master-agent"
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"]; let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
let readState: (typeof import("../src/lib/boss-data"))["readState"]; let readState: (typeof import("../src/lib/boss-data"))["readState"];
let updateAiAccountHealth: (typeof import("../src/lib/boss-data"))["updateAiAccountHealth"]; let updateAiAccountHealth: (typeof import("../src/lib/boss-data"))["updateAiAccountHealth"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
async function setup() { async function setup() {
if (runtimeRoot) return; if (runtimeRoot) return;
@@ -26,6 +27,18 @@ async function setup() {
saveAiAccount = data.saveAiAccount; saveAiAccount = data.saveAiAccount;
readState = data.readState; readState = data.readState;
updateAiAccountHealth = data.updateAiAccountHealth; updateAiAccountHealth = data.updateAiAccountHealth;
updateProjectAgentControls = data.updateProjectAgentControls;
}
async function waitFor(predicate: () => Promise<boolean>, timeoutMs = 5_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("waitFor timed out");
} }
test.after(async () => { test.after(async () => {
@@ -322,6 +335,74 @@ test("replyToMasterAgentUserMessage retries the next ready API backup when the f
} }
}); });
test("replyToMasterAgentUserMessage 在快速反应模式遇到复杂请求时会自动切到深度思考模型并排队执行", async () => {
await saveAiAccount({
accountId: "openai-primary-smart-upgrade",
label: "OpenAI 主控",
role: "primary",
provider: "openai_api",
displayName: "OpenAI 主账号",
model: "gpt-5.4",
apiKey: "sk-openai-smart-upgrade",
enabled: true,
setActive: true,
loginStatusNote: "主 OpenAI 账号。",
});
await updateProjectAgentControls("master-agent", {
modelOverride: "gpt-4.1",
reasoningEffortOverride: "low",
fastModelOverride: "gpt-4.1",
deepModelOverride: "gpt-5.4",
});
const fetchCalls: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input, init) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body ?? null;
fetchCalls.push({ url: String(input), body });
return new Response(JSON.stringify({ output_text: "已经自动切到深度思考模型。" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-request-id": "req-master-smart-upgrade",
},
});
}) as typeof fetch;
try {
const result = await replyToMasterAgentUserMessage({
requestMessageId: "msg-master-smart-upgrade",
requestText: "请深入分析当前主 Agent 架构,并给出分阶段实现方案、风险和回归测试建议。",
requestedBy: "Boss 超级管理员",
requestedByAccount: "17600003315",
mode: "smart",
});
assert.equal(result.ok, true);
assert.equal(result.accountId, "openai-primary-smart-upgrade");
assert.equal(result.masterReplyState, "queued");
assert.equal(result.autoEscalated, true);
assert.ok(result.taskId, "expected a queued task after smart deep-upgrade");
await waitFor(async () => {
const state = await readState();
const task = state.masterAgentTasks.find((item) => item.taskId === result.taskId);
return task?.status === "completed";
});
assert.equal(fetchCalls.length, 1);
const requestBody = fetchCalls[0]?.body as {
model?: string;
reasoning?: { effort?: string };
};
assert.equal(requestBody?.model, "gpt-5.4");
assert.equal(requestBody?.reasoning?.effort, "high");
} finally {
globalThis.fetch = originalFetch;
}
});
test("replyToMasterAgentUserMessage falls back to a ready backup master node account when API backends are unavailable", async () => { test("replyToMasterAgentUserMessage falls back to a ready backup master node account when API backends are unavailable", async () => {
await saveAiAccount({ await saveAiAccount({
accountId: "master-codex-primary-offline", accountId: "master-codex-primary-offline",

View File

@@ -12,8 +12,10 @@ let updateMasterAgentPromptPolicy: (typeof import("../src/lib/boss-data"))["upda
let updateUserMasterPrompt: (typeof import("../src/lib/boss-data"))["updateUserMasterPrompt"]; let updateUserMasterPrompt: (typeof import("../src/lib/boss-data"))["updateUserMasterPrompt"];
let createUserMasterMemory: (typeof import("../src/lib/boss-data"))["createUserMasterMemory"]; let createUserMasterMemory: (typeof import("../src/lib/boss-data"))["createUserMasterMemory"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"]; let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let appendProjectMessage: (typeof import("../src/lib/boss-data"))["appendProjectMessage"];
let resolveMasterAgentExecutionConfig: (typeof import("../src/lib/boss-master-agent"))["resolveMasterAgentExecutionConfig"]; let resolveMasterAgentExecutionConfig: (typeof import("../src/lib/boss-master-agent"))["resolveMasterAgentExecutionConfig"];
let replyToMasterAgentUserMessage: (typeof import("../src/lib/boss-master-agent"))["replyToMasterAgentUserMessage"]; let replyToMasterAgentUserMessage: (typeof import("../src/lib/boss-master-agent"))["replyToMasterAgentUserMessage"];
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
async function setup() { async function setup() {
if (runtimeRoot) return; if (runtimeRoot) return;
@@ -34,8 +36,10 @@ async function setup() {
updateUserMasterPrompt = data.updateUserMasterPrompt; updateUserMasterPrompt = data.updateUserMasterPrompt;
createUserMasterMemory = data.createUserMasterMemory; createUserMasterMemory = data.createUserMasterMemory;
updateProjectAgentControls = data.updateProjectAgentControls; updateProjectAgentControls = data.updateProjectAgentControls;
appendProjectMessage = data.appendProjectMessage;
resolveMasterAgentExecutionConfig = masterAgent.resolveMasterAgentExecutionConfig; resolveMasterAgentExecutionConfig = masterAgent.resolveMasterAgentExecutionConfig;
replyToMasterAgentUserMessage = masterAgent.replyToMasterAgentUserMessage; replyToMasterAgentUserMessage = masterAgent.replyToMasterAgentUserMessage;
completeMasterAgentTask = data.completeMasterAgentTask;
} }
test.after(async () => { test.after(async () => {
@@ -377,3 +381,87 @@ test("主 Agent 执行 prompt 在没有线程状态文档和进展事件时才
assert.ok(queuedTask?.executionPrompt.includes("关键时刻深拉线程兜底:")); assert.ok(queuedTask?.executionPrompt.includes("关键时刻深拉线程兜底:"));
assert.ok(queuedTask?.executionPrompt.includes("深拉兜底目标")); assert.ok(queuedTask?.executionPrompt.includes("深拉兜底目标"));
}); });
test("项目理解同步 prompt 强制线程先基于本地文档和代码汇总,并允许回写版本记录摘要", async () => {
await setup();
const state = await readState();
state.projects.push({
id: "understanding-sync-thread",
name: "项目理解同步线程",
pinned: false,
deviceIds: ["mac-studio"],
preview: "等待同步",
updatedAt: "2026-04-04T18:00:00+08:00",
lastMessageAt: "2026-04-04T18:00:00+08:00",
isGroup: false,
threadMeta: {
projectId: "understanding-sync-thread",
threadId: "thread-understanding-sync",
threadDisplayName: "项目理解同步线程",
folderName: "理解同步",
activityIconCount: 1,
updatedAt: "2026-04-04T18:00:00+08:00",
lastObservedCodexActivityAt: "2026-04-04T18:00:00+08:00",
codexThreadRef: "thread-understanding-sync",
codexFolderRef: "understanding-sync-folder",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 0,
riskLevel: "low",
messages: [],
goals: [],
versions: [],
});
const project = state.projects.find((item) => item.id === "understanding-sync-thread");
assert.ok(project, "expected seeded understanding-sync-thread project");
project!.versions = [];
project!.threadMeta.lastObservedCodexActivityAt = "2026-04-04T18:00:00+08:00";
await writeState(state);
await updateProjectAgentControls("understanding-sync-thread", {
takeoverEnabled: true,
});
await appendProjectMessage({
projectId: "understanding-sync-thread",
sender: "device",
senderLabel: "项目理解同步线程",
body: "已根据本地开发文档补齐线程状态同步。",
kind: "text",
});
const queuedTask = (await readState()).masterAgentTasks.find(
(task) =>
task.projectId === "master-agent" &&
task.projectUnderstandingTargetProjectId === "understanding-sync-thread",
);
assert.ok(queuedTask, "expected project understanding sync task");
assert.match(queuedTask!.executionPrompt, /先基于当前项目本地可见的开发文档和实际代码进行汇总/);
assert.match(queuedTask!.executionPrompt, /优先检查 README、docs、架构文档、版本记录和最近改动的关键代码文件/);
assert.match(queuedTask!.executionPrompt, /"versionRecord": "一句中文版本记录摘要"/);
await completeMasterAgentTask({
taskId: queuedTask!.taskId,
deviceId: "mac-studio",
status: "completed",
replyBody: JSON.stringify({
projectGoal: "完成审计对话线程状态同步",
currentProgress: "已切到线程状态文档优先",
technicalArchitecture: "Next.js 控制面配合 Android 原生客户端",
currentBlockers: "",
recommendedNextStep: "继续补强版本记录同步",
versionRecord: "项目理解同步已改成先读本地文档和代码,再回写结构化摘要。",
}),
});
const refreshed = await readState();
const refreshedProject = refreshed.projects.find((item) => item.id === "understanding-sync-thread");
assert.ok(refreshedProject, "expected refreshed project");
assert.equal(
refreshedProject!.projectUnderstanding?.projectGoal,
"完成审计对话线程状态同步",
);
assert.equal(refreshedProject!.versions[0]?.summary, "项目理解同步已改成先读本地文档和代码,再回写结构化摘要。");
});

View File

@@ -10,6 +10,9 @@ let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
let toggleGoal: (typeof import("../src/lib/boss-data"))["toggleGoal"]; let toggleGoal: (typeof import("../src/lib/boss-data"))["toggleGoal"];
let updateGoalText: (typeof import("../src/lib/boss-data"))["updateGoalText"]; let updateGoalText: (typeof import("../src/lib/boss-data"))["updateGoalText"];
let createGoal: (typeof import("../src/lib/boss-data"))["createGoal"]; let createGoal: (typeof import("../src/lib/boss-data"))["createGoal"];
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
let forceProjectUnderstandingSyncTask: (typeof import("../src/lib/boss-data"))["forceProjectUnderstandingSyncTask"];
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"]; let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
async function setup() { async function setup() {
@@ -28,6 +31,9 @@ async function setup() {
toggleGoal = data.toggleGoal; toggleGoal = data.toggleGoal;
updateGoalText = data.updateGoalText; updateGoalText = data.updateGoalText;
createGoal = data.createGoal; createGoal = data.createGoal;
updateProjectAgentControls = data.updateProjectAgentControls;
completeMasterAgentTask = data.completeMasterAgentTask;
forceProjectUnderstandingSyncTask = data.forceProjectUnderstandingSyncTask;
subscribeBossEvents = events.subscribeBossEvents; subscribeBossEvents = events.subscribeBossEvents;
} }
@@ -111,3 +117,56 @@ test("createGoal publishes project goal refresh marker for the project", async (
assert.equal(latest.payload.projectId, "project-goal-events"); assert.equal(latest.payload.projectId, "project-goal-events");
assert.equal(latest.payload.note, "project_goals.updated"); assert.equal(latest.payload.note, "project_goals.updated");
}); });
test("project understanding sync completion also publishes project goal refresh marker", async () => {
const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = [];
const unsubscribe = subscribeBossEvents((event, payload) => {
events.push({ event, payload });
});
const state = await readState();
const existingProject = state.projects.find((project) => project.id !== "master-agent");
assert.ok(existingProject);
const project = structuredClone(existingProject);
project.id = "project-goal-understanding-sync";
project.name = "项目目标理解同步测试";
project.goals = [];
project.versions = [];
project.messages = [];
project.lastMessageAt = "2026-04-07T10:00:00.000Z";
project.threadMeta.lastObservedCodexActivityAt = "2026-04-07T10:00:00.000Z";
state.projects = state.projects.filter((item) => item.id !== project.id);
state.projects.unshift(project);
await writeState(state);
await updateProjectAgentControls(project.id, { takeoverEnabled: true });
const queuedTask = await forceProjectUnderstandingSyncTask({
projectId: project.id,
observedActivityAt: "2026-04-07T10:00:00.000Z",
reason: "thread_reply",
});
assert.ok(queuedTask);
await completeMasterAgentTask({
taskId: queuedTask!.taskId,
deviceId: "mac-studio",
status: "completed",
replyBody: JSON.stringify({
projectGoal: "完成项目目标和版本记录自动同步",
currentProgress: "项目目标页需要展示主 Agent 最新核对结果",
technicalArchitecture: "Boss Web 与 Android 共用文件账本状态",
currentBlockers: "",
recommendedNextStep: "继续优化聊天阅读排版",
versionRecord: "已补项目目标/版本记录的自动同步链路。",
}),
});
unsubscribe();
const goalRefreshEvent = events.find(
(item) =>
item.event === "conversation.updated" &&
item.payload.projectId === project.id &&
item.payload.note === "project_goals.updated",
);
assert.ok(goalRefreshEvent, "expected project understanding sync to publish a goal refresh marker");
});

View File

@@ -59,8 +59,36 @@ test("project conversation pages wire project-scoped realtime refresh", async ()
); );
assert.match( assert.match(
versionsPage, versionsPage,
/conversationUpdatedNotes=\{\["project_goals\.updated"\]\}/, /conversationUpdatedNotes=\{\["project_versions\.updated"\]\}/,
"expected versions page to refresh only on project goal markers", "expected versions page to refresh only on project version markers",
);
});
test("project goal and version pages expose compact synced summaries", async () => {
const [goalsPage, versionsPage] = await Promise.all([
readWorkspaceFile("src/app/conversations/[projectId]/goals/page.tsx"),
readWorkspaceFile("src/app/conversations/[projectId]/versions/page.tsx"),
]);
assert.match(
goalsPage,
/project\.projectUnderstanding\?\.projectGoal/,
"expected goals page to surface the latest synced project goal summary",
);
assert.match(
goalsPage,
/project\.projectUnderstanding\?\.currentProgress/,
"expected goals page to surface the latest synced project progress summary",
);
assert.match(
goalsPage,
/project\.projectUnderstanding\?\.recommendedNextStep/,
"expected goals page to surface the latest synced recommended next step",
);
assert.match(
versionsPage,
/PageNav title="版本记录"/,
"expected versions page to use the compact title copy",
); );
}); });

View File

@@ -12,6 +12,7 @@ let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSessio
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 = ""; let AUTH_SESSION_COOKIE = "";
const TEST_ACCOUNT = "17600003315";
async function setup() { async function setup() {
if (runtimeRoot) { if (runtimeRoot) {
@@ -45,7 +46,7 @@ test.after(async () => {
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: TEST_ACCOUNT,
role: "highest_admin", role: "highest_admin",
displayName: "Boss 超级管理员", displayName: "Boss 超级管理员",
loginMethod: "password", loginMethod: "password",
@@ -120,10 +121,41 @@ async function ensureSingleThreadProject() {
return findSingleThreadProject(nextState); return findSingleThreadProject(nextState);
} }
async function setProjectTakeover(projectId: string, enabled: boolean) {
const state = await readState();
state.userProjectAgentControls = state.userProjectAgentControls.filter(
(item) => !(item.projectId === projectId && item.account === TEST_ACCOUNT),
);
state.userProjectAgentControls.unshift({
account: TEST_ACCOUNT,
projectId,
controls: {
takeoverEnabled: enabled,
updatedAt: enabled ? "2026-04-17T10:00:00.000Z" : "2026-04-17T10:10:00.000Z",
},
});
await writeState(state);
}
async function resetThreadExecutionState(projectId: string) {
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
const targetDevice = project ? state.devices.find((device) => device.id === project.deviceIds[0]) : null;
if (targetDevice) {
targetDevice.preferredExecutionMode = "cli";
}
state.projectExecutionPolicies = state.projectExecutionPolicies.filter((policy) => policy.projectId !== projectId);
state.userProjectAgentControls = state.userProjectAgentControls.filter(
(item) => !(item.projectId === projectId && item.account === TEST_ACCOUNT),
);
await writeState(state);
}
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 () => {
await setup(); await setup();
const singleProject = await ensureSingleThreadProject(); const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project"); assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
const response = await postMessageRoute( const response = await postMessageRoute(
await createAuthedRequest( await createAuthedRequest(
@@ -157,17 +189,146 @@ test("POST /api/v1/projects/[projectId]/messages enqueues a conversation task fo
assert.ok(task, "expected a queued conversation_reply task for the single-thread project"); assert.ok(task, "expected a queued conversation_reply task for the single-thread project");
assert.equal(task?.targetProjectId, singleProject.id); assert.equal(task?.targetProjectId, singleProject.id);
assert.equal(task?.targetThreadId, singleProject.threadMeta.threadId); assert.equal(task?.targetThreadId, singleProject.threadMeta.threadId);
assert.equal(task?.targetCodexThreadRef, singleProject.threadMeta.codexThreadRef);
assert.equal(task?.targetCodexFolderRef, singleProject.threadMeta.codexFolderRef);
assert.ok(task?.executionPrompt?.includes("请同步一下当前阻塞情况")); assert.ok(task?.executionPrompt?.includes("请同步一下当前阻塞情况"));
assert.ok(task?.executionPrompt?.includes(singleProject.threadMeta.threadDisplayName)); assert.ok(task?.executionPrompt?.includes(singleProject.threadMeta.threadDisplayName));
assert.ok(!task?.executionPrompt?.includes("threadProjectId:"), "thread prompt should not include project id labels"); assert.ok(!task?.executionPrompt?.includes("threadProjectId:"), "thread prompt should not include project id labels");
assert.ok(!task?.executionPrompt?.includes("folderName:"), "thread prompt should not include folder labels"); assert.ok(!task?.executionPrompt?.includes("folderName:"), "thread prompt should not include folder labels");
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");
assert.equal(task?.relayViaMasterAgent, undefined);
});
test("POST /api/v1/projects/[projectId]/messages routes takeover mode to master-agent conversation first", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
await setProjectTakeover(singleProject.id, true);
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 payload = (await response.json()) as {
ok: boolean;
replyPresenter?: "thread" | "master";
task?: { taskId: string; taskType: string; status: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.replyPresenter, "master");
assert.equal(payload.task?.taskType, "conversation_reply");
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_reply task for takeover mode");
assert.equal(task?.relayViaMasterAgent, true);
assert.equal(task?.targetProjectId, undefined);
assert.equal(task?.targetThreadId, undefined);
assert.equal(task?.targetCodexThreadRef, undefined);
assert.equal(task?.targetCodexFolderRef, undefined);
assert.ok(task?.executionPrompt?.includes("主 Agent"));
assert.ok(task?.executionPrompt?.includes("协同接管"));
assert.ok(task?.executionPrompt?.includes("先准确理解并确认用户意图"));
});
test("takeover prompt asks master agent to sync verified goals and version records when user requests review", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
await setProjectTakeover(singleProject.id, true);
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 payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task?.taskType, "conversation_reply");
const nextState = await readState();
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
assert.ok(task, "expected a queued takeover task");
assert.equal(task?.relayViaMasterAgent, true);
assert.match(task!.executionPrompt, /用户要求核对或更新项目目标、版本记录时/);
assert.match(task!.executionPrompt, /先让当前线程基于本地开发文档和实际代码重新汇总/);
assert.match(task!.executionPrompt, /自动同步到当前会话顶部的“项目目标”和“版本记录”入口/);
const understandingTask = nextState.masterAgentTasks.find(
(item) =>
item.projectId === "master-agent" &&
item.projectUnderstandingTargetProjectId === singleProject.id &&
item.status === "queued",
);
assert.ok(understandingTask, "expected a hidden project understanding sync task");
assert.match(understandingTask!.executionPrompt, /先基于当前项目本地可见的开发文档和实际代码进行汇总/);
});
test("POST /api/v1/projects/[projectId]/messages still lets takeover mode talk to master agent during gui conflict", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
await setProjectTakeover(singleProject.id, true);
const state = await readState();
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
assert.ok(targetDevice, "expected a seeded target device");
targetDevice.preferredExecutionMode = "gui";
await writeState(state);
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 payload = (await response.json()) as {
ok: boolean;
replyPresenter?: "thread" | "master";
task?: { taskId: string; taskType: string; status: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.replyPresenter, "master");
assert.equal(payload.task?.taskType, "conversation_reply");
const nextState = await readState();
const task = nextState.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
assert.ok(task, "expected takeover mode to queue a master-agent task");
assert.equal(task?.relayViaMasterAgent, true);
assert.equal(task?.targetProjectId, undefined);
}); });
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();
assert.ok(singleProject, "expected a seeded single-thread project"); assert.ok(singleProject, "expected a seeded single-thread project");
await setProjectTakeover(singleProject.id, false);
const state = await readState(); const state = await readState();
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]); const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
@@ -227,6 +388,7 @@ test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when
await setup(); await setup();
const singleProject = await ensureSingleThreadProject(); const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project"); assert.ok(singleProject, "expected a seeded single-thread project");
await setProjectTakeover(singleProject.id, false);
const state = await readState(); const state = await readState();
const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]); const targetDevice = state.devices.find((device) => device.id === singleProject.deviceIds[0]);
@@ -289,12 +451,139 @@ test("POST /api/v1/projects/[projectId]/messages blocks single-thread sends when
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task"); assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
}); });
test("POST /api/v1/projects/[projectId]/messages blocks before queueing when recent codex activity exists without a stored policy", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
await setProjectTakeover(singleProject.id, false);
const recentExternalActivityAt = new Date(Date.now() - 60_000).toISOString();
const state = await readState();
await writeState({
...state,
projects: state.projects.map((project) =>
project.id === singleProject.id
? {
...project,
threadMeta: {
...project.threadMeta,
lastObservedCodexActivityAt: recentExternalActivityAt,
},
}
: project,
),
projectExecutionPolicies: state.projectExecutionPolicies.filter(
(policy) => policy.projectId !== singleProject.id,
),
});
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, 409);
const payload = (await response.json()) as {
ok: boolean;
code?: string;
executionConflict?: {
projectId: string;
preferredExecutionMode: "gui" | "cli";
allowPolicy: "forbid" | "allow_once" | "allow_always";
conflictState: "none" | "warning" | "blocked";
reason: string;
};
};
assert.equal(payload.ok, false);
assert.equal(payload.code, "THREAD_EXECUTION_CONFLICT");
assert.equal(payload.executionConflict?.projectId, singleProject.id);
assert.equal(payload.executionConflict?.preferredExecutionMode, "cli");
assert.equal(payload.executionConflict?.allowPolicy, "forbid");
assert.equal(payload.executionConflict?.conflictState, "blocked");
assert.equal(payload.executionConflict?.reason, "project_conflict_forbid");
const nextState = await readState();
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const blockedMessage = updatedProject?.messages.find((message) =>
message.body.includes("请看一下这个项目现在卡在哪里"),
);
assert.equal(blockedMessage, undefined, "blocked send should not append a local chat message");
const queuedTask = nextState.masterAgentTasks.find(
(item) =>
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.requestText === "请看一下这个项目现在卡在哪里",
);
assert.equal(queuedTask, undefined, "blocked send should not enqueue a conversation task");
});
test("POST /api/v1/projects/[projectId]/messages ignores stale scoped conflict policies", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
const staleExternalActivityAt = new Date(Date.now() - 30 * 60_000).toISOString();
const state = await readState();
await writeState({
...state,
projects: state.projects.map((project) =>
project.id === singleProject.id
? {
...project,
threadMeta: {
...project.threadMeta,
lastObservedCodexActivityAt: staleExternalActivityAt,
},
}
: project,
),
projectExecutionPolicies: [
...state.projectExecutionPolicies.filter((policy) => policy.projectId !== singleProject.id),
{
deviceId: singleProject.deviceIds[0],
folderKey: buildProjectFolderKey(singleProject),
projectId: singleProject.id,
allowPolicy: "forbid" as const,
conflictState: "blocked" as const,
recentExternalActivityAt: staleExternalActivityAt,
updatedAt: staleExternalActivityAt,
},
],
});
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 payload = (await response.json()) as {
ok: boolean;
task?: { taskId: string; taskType: string; status: string } | null;
};
assert.equal(payload.ok, true);
assert.equal(payload.task?.taskType, "conversation_reply");
assert.equal(payload.task?.status, "queued");
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => { test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread reply back to the single-thread project", async () => {
await setup(); await setup();
const singleProject = await ensureSingleThreadProject(); const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project"); assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
await setProjectTakeover(singleProject.id, false);
await postMessageRoute( const sendResponse = await postMessageRoute(
await createAuthedRequest( await createAuthedRequest(
`http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`, `http://127.0.0.1:3000/api/v1/projects/${singleProject.id}/messages`,
"POST", "POST",
@@ -302,13 +591,13 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread re
), ),
{ params: Promise.resolve({ projectId: singleProject.id }) }, { params: Promise.resolve({ projectId: singleProject.id }) },
); );
const sendPayload = (await sendResponse.json()) as {
task?: { taskId: string };
};
const queuedState = await readState(); const queuedState = await readState();
const task = queuedState.masterAgentTasks.find( const task = queuedState.masterAgentTasks.find(
(item) => (item) => item.taskId === sendPayload.task?.taskId,
item.taskType === "conversation_reply" &&
item.projectId === singleProject.id &&
item.targetProjectId === singleProject.id,
); );
assert.ok(task, "expected a queued conversation_reply task"); assert.ok(task, "expected a queued conversation_reply task");
@@ -337,10 +626,66 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete writes the raw thread re
assert.equal(mirroredReply?.sender, "device"); assert.equal(mirroredReply?.sender, "device");
}); });
test("POST /api/v1/master-agent/tasks/[taskId]/complete writes takeover master replies to the current project", async () => {
await setup();
const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
await setProjectTakeover(singleProject.id, true);
const sendResponse = 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 sendPayload = (await sendResponse.json()) as {
task?: { taskId: string };
};
const queuedState = await readState();
const task = queuedState.masterAgentTasks.find(
(item) => item.taskId === sendPayload.task?.taskId,
);
assert.ok(task, "expected a queued conversation_reply task");
assert.equal(task?.relayViaMasterAgent, true);
assert.equal(task?.targetProjectId, undefined);
assert.equal(task?.targetThreadId, undefined);
await setProjectTakeover(singleProject.id, false);
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",
replyBody: "我先确认一下:你是希望我梳理当前阻塞后,再协调目标线程继续推进,对吗?",
},
),
{ params: Promise.resolve({ taskId: task.taskId }) },
);
assert.equal(response.status, 200);
const nextState = await readState();
const updatedProject = nextState.projects.find((project) => project.id === singleProject.id);
const relayedReply = updatedProject?.messages.find((message) =>
message.body.includes("我先确认一下:你是希望我梳理当前阻塞后,再协调目标线程继续推进,对吗?"),
);
assert.ok(relayedReply, "expected a master reply to be written back to the current project");
assert.equal(relayedReply?.sender, "master");
assert.match(relayedReply?.senderLabel ?? "", /主 Agent/);
});
test("POST /api/v1/master-agent/tasks/[taskId]/complete blocks leaked thread environment diagnostics from the chat transcript", async () => { test("POST /api/v1/master-agent/tasks/[taskId]/complete blocks leaked thread environment diagnostics from the chat transcript", async () => {
await setup(); await setup();
const singleProject = await ensureSingleThreadProject(); const singleProject = await ensureSingleThreadProject();
assert.ok(singleProject, "expected a seeded single-thread project"); assert.ok(singleProject, "expected a seeded single-thread project");
await resetThreadExecutionState(singleProject.id);
await setProjectTakeover(singleProject.id, false);
await postMessageRoute( await postMessageRoute(
await createAuthedRequest( await createAuthedRequest(

View File

@@ -111,7 +111,7 @@ test("thread status documents and progress events normalize, sort, and trim corr
); );
}); });
test("thread replies append lightweight progress events without queuing a fresh understanding sync", async () => { test("thread replies append lightweight progress events and skip redundant understanding sync when status document is fresh", async () => {
await setup(); await setup();
const state = (await readState()) as MutableBossState; const state = (await readState()) as MutableBossState;