chore: checkpoint Boss app v2.5.11

This commit is contained in:
AI Bot
2026-06-08 12:22:50 +08:00
parent bddbe8b5ba
commit 3b51641d99
78 changed files with 5706 additions and 954 deletions

View File

@@ -172,11 +172,23 @@ public class BossApiClient {
}
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
return requestWithRestoreRaw(
"GET",
"/api/v1/projects/" + encode(projectId),
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
}
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null);
return requestWithRestoreRaw(
"GET",
"/api/v1/projects/" + encode(projectId) + "/messages",
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
}
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
@@ -560,6 +572,14 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
}
public ApiResponse getSkillLifecycleRequests() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/admin/skills/requests", null);
}
public ApiResponse createSkillLifecycleRequest(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/admin/skills/requests", payload == null ? new JSONObject() : payload);
}
public ApiResponse getDeviceEnrollments() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/enrollments", null);
}

View File

@@ -29,6 +29,7 @@ public final class BossMarkdown {
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\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 MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]\\n]{1,90})\\]\\((https?://[^\\s)]+)\\)");
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180);
@@ -45,7 +46,7 @@ public final class BossMarkdown {
}
Palette palette = Palette.resolve(context, outgoing);
SpannableStringBuilder builder = new SpannableStringBuilder();
String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n');
String normalized = normalizeMarkdownLinks(markdown).replace("\r\n", "\n").replace('\r', '\n');
String[] lines = normalized.split("\n", -1);
boolean inCodeFence = false;
List<String> codeLines = new ArrayList<>();
@@ -117,6 +118,21 @@ public final class BossMarkdown {
return (outgoing ? "out" : "in") + "|" + uiMode + "|" + markdown;
}
private static String normalizeMarkdownLinks(String markdown) {
Matcher matcher = MARKDOWN_LINK_PATTERN.matcher(markdown);
StringBuffer buffer = new StringBuffer();
while (matcher.find()) {
String label = matcher.group(1) == null ? "链接" : matcher.group(1).trim();
label = label.replace("`", "").trim();
if (TextUtils.isEmpty(label)) {
label = "链接";
}
matcher.appendReplacement(buffer, Matcher.quoteReplacement(label));
}
matcher.appendTail(buffer);
return buffer.toString();
}
private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) {
ensureBlockSeparation(builder, true);
int start = builder.length();

View File

@@ -136,6 +136,7 @@ final class BossRealtimeClient {
connection.setRequestProperty("Accept", "text/event-stream");
connection.setRequestProperty("Cache-Control", "no-cache");
connection.setRequestProperty("x-boss-native-app", "1");
connection.setRequestProperty("x-boss-realtime-capabilities", "message-patch-v1");
String cookie = apiClient.getSessionCookie();
if (!cookie.isEmpty()) {
connection.setRequestProperty("Cookie", cookie);

View File

@@ -1340,6 +1340,7 @@ public final class BossUi {
card.addView(titleRow);
String phase = progress == null ? "" : progress.optString("phase", "").trim();
String progressStatus = progress == null ? "" : progress.optString("status", "").trim();
if (!TextUtils.isEmpty(phase)) {
TextView phaseView = secondaryText(context, "当前状态:" + executionPhaseLabel(phase));
phaseView.setPadding(0, dp(context, 8), 0, 0);
@@ -1872,9 +1873,14 @@ public final class BossUi {
}
}
card.addView(divider(context));
card.addView(sectionTitle(context, "分支详情"));
JSONObject branch = progress == null ? null : progress.optJSONObject("branch");
boolean shouldShowBranchFallback = TextUtils.isEmpty(progressStatus) ||
"queued".equals(progressStatus) ||
"running".equals(progressStatus);
if (branch != null || shouldShowBranchFallback) {
card.addView(divider(context));
card.addView(sectionTitle(context, "分支详情"));
}
if (branch != null) {
String changeText = formatChangeText(branch);
if (!TextUtils.isEmpty(changeText)) {
@@ -1888,7 +1894,7 @@ public final class BossUi {
} else if ("available".equals(ghStatus)) {
card.addView(detailRow(context, "", "GitHub CLI 可用", "", false));
}
} else {
} else if (shouldShowBranchFallback) {
card.addView(detailRow(context, "", "Git 操作", "等待执行器回写", false));
}

View File

@@ -128,6 +128,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
private boolean pendingReload;
private boolean pendingReloadMessagesOnly;
private boolean pendingReloadForcedScrollToBottom;
private boolean pendingReloadShowRefreshIndicator;
private volatile boolean activityDestroyed;
private volatile boolean markConversationReadInFlight;
private @Nullable AlertDialog activeDialogGuardDialog;
@@ -141,7 +142,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
return;
}
if (!reloadInFlight && !isComposerBusy() && shouldAutoRefreshConversation()) {
reload(false);
reloadInBackground(false);
}
armConversationAutoRefresh();
}
@@ -543,6 +544,10 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (event == null || !"project.messages.updated".equals(event.eventName)) {
return false;
}
JSONObject projectMessagesPatch = event.payload.optJSONObject("projectMessagesPatch");
if (projectMessagesPatch != null) {
return tryApplyRealtimeMessageWindowPatch(projectMessagesPatch);
}
JSONObject projectMessagesPayload = event.payload.optJSONObject("projectMessagesPayload");
if (projectMessagesPayload == null) {
return false;
@@ -565,6 +570,167 @@ public class ProjectDetailActivity extends BossScreenActivity {
return true;
}
private boolean tryApplyRealtimeMessageWindowPatch(JSONObject projectMessagesPatch) {
if (projectMessagesPatch == null || !"message_window".equals(projectMessagesPatch.optString("patchKind", ""))) {
return false;
}
if (currentRenderedProjectPayload == null || contentLayout == null) {
scheduleRealtimeReload(false);
return true;
}
if (selectionState != null && selectionState.multiSelecting) {
scheduleRealtimeReload(false);
return true;
}
JSONObject patchProject = projectMessagesPatch.optJSONObject("project");
JSONArray patchMessages = projectMessagesPatch.optJSONArray("messages");
if (patchProject == null || patchMessages == null) {
scheduleRealtimeReload(false);
return true;
}
JSONObject currentPayload = copyJson(currentRenderedProjectPayload);
JSONObject currentProject = currentPayload.optJSONObject("project");
if (currentProject == null || !TextUtils.equals(
currentProject.optString("id", "").trim(),
projectMessagesPatch.optString("projectId", "").trim()
)) {
scheduleRealtimeReload(false);
return true;
}
JSONArray currentMessages = currentProject.optJSONArray("messages");
if (currentMessages == null) {
scheduleRealtimeReload(false);
return true;
}
int expectedMessageCount = projectMessagesPatch.optInt("messageCount", currentMessages.length());
if (expectedMessageCount < currentMessages.length()) {
scheduleRealtimeReload(false);
return true;
}
PatchMergeResult mergeResult = mergeMessageWindowPatch(currentMessages, patchMessages, expectedMessageCount);
if (!mergeResult.ok) {
scheduleRealtimeReload(false);
return true;
}
try {
copyProjectPatchMetadata(currentProject, patchProject);
currentProject.put("messages", mergeResult.messages);
JSONArray devices = projectMessagesPatch.optJSONArray("devices");
if (devices != null) {
currentPayload.put("devices", devices);
}
} catch (org.json.JSONException ignored) {
scheduleRealtimeReload(false);
return true;
}
JSONObject nextPayload = currentPayload;
runOnUiThread(() -> {
renderNearBottom = isChatNearBottom();
renderForcedScrollToBottom = false;
renderLoadedProjectSnapshot(new ProjectSnapshot(nextPayload, null, null));
});
return true;
}
private PatchMergeResult mergeMessageWindowPatch(
JSONArray currentMessages,
JSONArray patchMessages,
int expectedMessageCount
) {
JSONArray nextMessages = new JSONArray();
for (int i = 0; i < currentMessages.length(); i++) {
JSONObject message = currentMessages.optJSONObject(i);
if (message != null) {
nextMessages.put(copyJson(message));
}
}
boolean changed = false;
Map<String, Integer> currentIndexById = new HashMap<>();
for (int i = 0; i < nextMessages.length(); i++) {
JSONObject message = nextMessages.optJSONObject(i);
String messageId = message == null ? "" : message.optString("id", "");
if (!TextUtils.isEmpty(messageId)) {
currentIndexById.put(messageId, i);
}
}
String currentLastId = latestMessageId(nextMessages);
int currentLastIndexInPatch = -1;
for (int i = 0; i < patchMessages.length(); i++) {
JSONObject patchMessage = patchMessages.optJSONObject(i);
String patchMessageId = patchMessage == null ? "" : patchMessage.optString("id", "");
if (TextUtils.isEmpty(patchMessageId)) {
continue;
}
Integer existingIndex = currentIndexById.get(patchMessageId);
if (existingIndex != null) {
JSONObject existing = nextMessages.optJSONObject(existingIndex);
if (existing == null || !TextUtils.equals(existing.toString(), patchMessage.toString())) {
try {
nextMessages.put(existingIndex, copyJson(patchMessage));
changed = true;
} catch (org.json.JSONException ignored) {
return PatchMergeResult.failed();
}
}
}
if (TextUtils.equals(currentLastId, patchMessageId)) {
currentLastIndexInPatch = i;
}
}
if (expectedMessageCount > nextMessages.length()) {
if (currentLastIndexInPatch < 0) {
return PatchMergeResult.failed();
}
for (int i = currentLastIndexInPatch + 1; i < patchMessages.length(); i++) {
JSONObject patchMessage = patchMessages.optJSONObject(i);
String patchMessageId = patchMessage == null ? "" : patchMessage.optString("id", "");
if (TextUtils.isEmpty(patchMessageId) || currentIndexById.containsKey(patchMessageId)) {
continue;
}
nextMessages.put(copyJson(patchMessage));
currentIndexById.put(patchMessageId, nextMessages.length() - 1);
changed = true;
}
}
if (nextMessages.length() != expectedMessageCount) {
return PatchMergeResult.failed();
}
return new PatchMergeResult(true, changed, nextMessages);
}
private String latestMessageId(@Nullable JSONArray messages) {
if (messages == null || messages.length() == 0) {
return "";
}
JSONObject message = messages.optJSONObject(messages.length() - 1);
return message == null ? "" : message.optString("id", "");
}
private void copyProjectPatchMetadata(JSONObject currentProject, JSONObject patchProject) {
putIfPresent(currentProject, patchProject, "name");
putIfPresent(currentProject, patchProject, "threadMeta");
putIfPresent(currentProject, patchProject, "unreadCount");
putIfPresent(currentProject, patchProject, "isGroup");
putIfPresent(currentProject, patchProject, "collaborationMode");
putIfPresent(currentProject, patchProject, "approvalState");
putIfPresent(currentProject, patchProject, "lightDispatchReminderEnabled");
putIfPresent(currentProject, patchProject, "lastMessageAt");
putIfPresent(currentProject, patchProject, "updatedAt");
}
private void putIfPresent(JSONObject target, JSONObject source, String key) {
if (target == null || source == null || !source.has(key)) {
return;
}
try {
target.put(key, source.opt(key));
} catch (org.json.JSONException ignored) {
}
}
private boolean trySkipUnchangedRealtimeMessagesPatch(JSONObject projectMessagesPayload) {
if (currentRenderedProjectPayload == null || projectMessagesPayload == null) {
return false;
@@ -939,7 +1105,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
void triggerRealtimeReload(boolean requireFullSnapshot) {
if (requireFullSnapshot) {
reload();
reloadInBackground(false);
return;
}
reloadMessagesOnly();
@@ -960,14 +1126,22 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
private void reload(boolean forcedScrollToBottom) {
reloadSnapshot(forcedScrollToBottom, false);
reloadSnapshot(forcedScrollToBottom, false, true);
}
private void reloadInBackground(boolean forcedScrollToBottom) {
reloadSnapshot(forcedScrollToBottom, false, false);
}
private void reloadMessagesOnly() {
reloadSnapshot(false, true);
reloadSnapshot(false, true, false);
}
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly) {
reloadSnapshot(forcedScrollToBottom, messagesOnly, true);
}
private void reloadSnapshot(boolean forcedScrollToBottom, boolean messagesOnly, boolean showRefreshIndicator) {
if (shouldSkipAsyncUiWork()) {
return;
}
@@ -983,12 +1157,15 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
pendingReload = true;
pendingReloadForcedScrollToBottom = pendingReloadForcedScrollToBottom || forcedScrollToBottom;
pendingReloadShowRefreshIndicator = pendingReloadShowRefreshIndicator || showRefreshIndicator;
return;
}
renderNearBottom = isChatNearBottom();
renderForcedScrollToBottom = forcedScrollToBottom;
reloadInFlight = true;
setRefreshing(true);
if (showRefreshIndicator) {
setRefreshing(true);
}
try {
executor.execute(() -> {
try {
@@ -1004,7 +1181,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
if (messagesOnly) {
reloadInFlight = false;
setRefreshing(false);
reload(forcedScrollToBottom);
reloadSnapshot(forcedScrollToBottom, false, showRefreshIndicator);
return;
}
handleProjectReloadFailure(error);
@@ -1014,7 +1191,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
});
} catch (RejectedExecutionException ignored) {
reloadInFlight = false;
setRefreshing(false);
if (showRefreshIndicator) {
setRefreshing(false);
}
}
}
@@ -1062,10 +1241,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
}
boolean forcedScrollToBottom = pendingReloadForcedScrollToBottom;
boolean messagesOnly = pendingReloadMessagesOnly;
boolean showRefreshIndicator = pendingReloadShowRefreshIndicator;
pendingReload = false;
pendingReloadMessagesOnly = false;
pendingReloadForcedScrollToBottom = false;
reloadSnapshot(forcedScrollToBottom, messagesOnly);
pendingReloadShowRefreshIndicator = false;
reloadSnapshot(forcedScrollToBottom, messagesOnly, showRefreshIndicator);
}
@Override
@@ -1271,7 +1452,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
&& !reloadInFlight
&& refreshLayout != null
&& !isComposerBusy()) {
reload();
reloadInBackground(false);
}
updateConversationAutoRefresh();
}
@@ -4110,4 +4291,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
return kind;
}
}
private static final class PatchMergeResult {
final boolean ok;
final boolean changed;
final JSONArray messages;
PatchMergeResult(boolean ok, boolean changed, JSONArray messages) {
this.ok = ok;
this.changed = changed;
this.messages = messages == null ? new JSONArray() : messages;
}
static PatchMergeResult failed() {
return new PatchMergeResult(false, false, new JSONArray());
}
}
}

View File

@@ -1,12 +1,18 @@
package com.hyzq.boss;
import android.app.AlertDialog;
import android.content.Intent;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.LinkedHashMap;
@@ -58,8 +64,18 @@ public class SkillInventoryActivity extends BossScreenActivity {
String targetDeviceId = resolveTargetDeviceId();
BossApiClient.ApiResponse response = apiClient.getDeviceSkills(targetDeviceId);
if (!response.ok()) throw new IllegalStateException(response.message());
JSONObject lifecyclePayload = null;
try {
BossApiClient.ApiResponse lifecycleResponse = apiClient.getSkillLifecycleRequests();
if (lifecycleResponse.ok()) {
lifecyclePayload = lifecycleResponse.json;
}
} catch (Exception ignored) {
lifecyclePayload = null;
}
deviceId = targetDeviceId;
runOnUiThread(() -> renderSkills(response.json));
JSONObject finalLifecyclePayload = lifecyclePayload;
runOnUiThread(() -> renderSkills(response.json, finalLifecyclePayload));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -203,9 +219,14 @@ public class SkillInventoryActivity extends BossScreenActivity {
}
private void renderSkills(JSONObject payload) {
renderSkills(payload, null);
}
private void renderSkills(JSONObject payload, @Nullable JSONObject lifecyclePayload) {
replaceContent();
JSONObject device = payload.optJSONObject("device");
JSONArray skills = payload.optJSONArray("skills");
boolean canManageLifecycle = lifecyclePayload != null;
if (device != null) {
deviceName = device.optString("name", deviceId);
@@ -220,6 +241,10 @@ public class SkillInventoryActivity extends BossScreenActivity {
));
}
if (canManageLifecycle) {
appendSkillManagementWorkspace(lifecyclePayload);
}
if (skills == null || skills.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。"));
setRefreshing(false);
@@ -244,8 +269,217 @@ public class SkillInventoryActivity extends BossScreenActivity {
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
if (canManageLifecycle) {
Button update = BossUi.buildMiniActionButton(this, "更新下发", true);
update.setOnClickListener(v -> queueSkillLifecycleRequest("update", skill, null, null, null, null, null));
Button rollback = BossUi.buildMiniActionButton(this, "回滚", false);
rollback.setOnClickListener(v -> showVersionedSkillRequestDialog("rollback", skill, "回滚", "rollbackToVersion"));
Button versionLock = BossUi.buildMiniActionButton(this, "版本锁定", false);
versionLock.setOnClickListener(v -> showVersionedSkillRequestDialog("version_lock", skill, "版本锁定", "lockedVersion"));
card.addView(BossUi.buildInlineActionRow(this, update, rollback, versionLock));
}
appendContent(card);
}
setRefreshing(false);
}
private void appendSkillManagementWorkspace(JSONObject lifecyclePayload) {
JSONArray requests = lifecyclePayload.optJSONArray("requests");
int requestCount = requests == null ? 0 : requests.length();
int queuedCount = countRequestsByStatus(requests, "queued");
int runningCount = countRunningRequests(requests);
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
"Skill 管理分发",
"安装、更新、回滚、版本锁定和账号权限分配统一在这里处理。",
"Skill 请求状态:待执行 " + queuedCount + " · 执行中 " + runningCount + " · 最近请求 " + requestCount,
null,
null
));
Button installRemote = BossUi.buildMiniActionButton(this, "安装远端 Skill", true);
installRemote.setOnClickListener(v -> showInstallSkillDialog());
Button grantPermission = BossUi.buildMiniActionButton(this, "分配权限", false);
grantPermission.setOnClickListener(v -> startActivity(new Intent(this, AccessManagementActivity.class)));
card.addView(BossUi.buildInlineActionRow(this, installRemote, grantPermission));
if (requests != null && requests.length() > 0) {
int maxRows = Math.min(3, requests.length());
for (int index = 0; index < maxRows; index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request == null) continue;
card.addView(BossUi.buildWechatMenuRow(
this,
"Skill 请求状态",
request.optString("action", "-") + " · " + request.optString("status", "-"),
request.optString("skillId", request.optString("sourceUrl", "-"))
+ " · " + request.optString("requestedAt", "-"),
null,
null
));
}
}
appendContent(card);
}
private void showInstallSkillDialog() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
int padding = BossUi.dp(this, 12);
form.setPadding(padding, padding, padding, 0);
EditText sourceUrl = buildSingleLineInput("Git URL 或可信来源 URL");
EditText targetVersion = buildSingleLineInput("目标版本,可选");
EditText checksum = buildSingleLineInput("SHA256 校验和,可选");
form.addView(sourceUrl);
form.addView(targetVersion);
form.addView(checksum);
new AlertDialog.Builder(this)
.setTitle("安装远端 Skill")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("下发", (dialog, which) -> {
String source = sourceUrl.getText().toString().trim();
if (TextUtils.isEmpty(source)) {
showMessage("请输入 Skill 来源 URL");
return;
}
queueSkillLifecycleRequest(
"install",
null,
source,
targetVersion.getText().toString().trim(),
checksum.getText().toString().trim(),
null,
null
);
})
.show();
}
private void showVersionedSkillRequestDialog(
String action,
JSONObject skill,
String title,
String versionField
) {
EditText input = buildSingleLineInput("请输入版本号");
new AlertDialog.Builder(this)
.setTitle(title)
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("下发", (dialog, which) -> {
String version = input.getText().toString().trim();
if (TextUtils.isEmpty(version)) {
showMessage("请输入版本号");
return;
}
if ("rollbackToVersion".equals(versionField)) {
queueSkillLifecycleRequest(action, skill, null, null, null, version, null);
} else {
queueSkillLifecycleRequest(action, skill, null, null, null, null, version);
}
})
.show();
}
private EditText buildSingleLineInput(String hint) {
EditText input = new EditText(this);
input.setHint(hint);
input.setSingleLine(true);
input.setInputType(InputType.TYPE_CLASS_TEXT);
int verticalPadding = BossUi.dp(this, 8);
input.setPadding(0, verticalPadding, 0, verticalPadding);
return input;
}
private void queueSkillLifecycleRequest(
String action,
@Nullable JSONObject skill,
@Nullable String sourceUrl,
@Nullable String targetVersion,
@Nullable String checksum,
@Nullable String rollbackToVersion,
@Nullable String lockedVersion
) {
try {
JSONObject payload = new JSONObject();
payload.put("action", action);
payload.put("deviceId", deviceId == null ? "" : deviceId);
if (skill != null) {
putIfNotBlank(payload, "skillId", skill.optString("skillId", ""));
}
putIfNotBlank(payload, "sourceUrl", sourceUrl);
putIfNotBlank(payload, "targetVersion", targetVersion);
putIfNotBlank(payload, "checksum", checksum);
putIfNotBlank(payload, "rollbackToVersion", rollbackToVersion);
putIfNotBlank(payload, "lockedVersion", lockedVersion);
putIfNotBlank(payload, "note", "boss-app-skill-management");
submitSkillLifecycleRequest(payload);
} catch (JSONException error) {
showMessage("Skill 请求构建失败:" + error.getMessage());
}
}
private void putIfNotBlank(JSONObject payload, String key, @Nullable String value) throws JSONException {
if (!TextUtils.isEmpty(value)) {
payload.put(key, value);
}
}
private void submitSkillLifecycleRequest(JSONObject payload) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.createSkillLifecycleRequest(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("Skill 请求已下发");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("Skill 请求失败:" + error.getMessage());
});
}
});
}
private static int countRequestsByStatus(@Nullable JSONArray requests, String status) {
if (requests == null) {
return 0;
}
int count = 0;
for (int index = 0; index < requests.length(); index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request != null && status.equalsIgnoreCase(request.optString("status", ""))) {
count += 1;
}
}
return count;
}
private static int countRunningRequests(@Nullable JSONArray requests) {
if (requests == null) {
return 0;
}
int count = 0;
for (int index = 0; index < requests.length(); index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request == null) continue;
String status = request.optString("status", "");
if ("claimed".equalsIgnoreCase(status)
|| "running".equalsIgnoreCase(status)
|| "processing".equalsIgnoreCase(status)) {
count += 1;
}
}
return count;
}
}

View File

@@ -260,6 +260,34 @@ public class BossApiClientDispatchPlansTest {
assertEquals("GET", connection.requestMethodValue);
}
@Test
public void getProjectDetailUsesExtendedReadTimeoutForChatPages() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectDetail("thread-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
}
@Test
public void getProjectMessagesUsesExtendedReadTimeoutForRealtimeRefreshes() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectMessages("thread-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
}
@Test
public void createMasterAgentMemoryWritesStructuredPayload() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"));

View File

@@ -57,6 +57,7 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
waitFor(() -> activity.messageReloadCount == 1);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
}
@@ -144,7 +145,8 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.messageReloadCount);
}
@@ -172,7 +174,8 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.messageReloadCount);
}
@@ -211,6 +214,7 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
waitFor(() -> activity.messageReloadCount == 1);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
}
@@ -441,7 +445,8 @@ public class ProjectDetailActivityRealtimeTest {
);
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
}
@Test

View File

@@ -1532,6 +1532,42 @@ public class ProjectDetailActivityUiTest {
assertFalse(viewTreeContainsText(messageView, "Codex"));
}
@Test
public void failedExecutionProgressWithoutBranchDoesNotRenderExecutorWaitingFallback() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "juyuwan")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "juyuwan");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject message = new JSONObject()
.put("id", "failed-progress-no-branch")
.put("sender", "master")
.put("senderLabel", "主 Agent")
.put("body", "执行进度:失败")
.put("kind", "execution_progress")
.put("sentAt", "2026-06-07T14:24:00+08:00")
.put("executionProgress", new JSONObject()
.put("title", "进度")
.put("controlMode", "codex_thread")
.put("status", "failed")
.put("phase", "terminal_failed")
.put("steps", new JSONArray()
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "failed"))));
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
"buildMessageView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "当前状态:执行失败"));
assertFalse(viewTreeContainsText(messageView, "等待执行器回写"));
assertFalse(viewTreeContainsText(messageView, "分支详情"));
}
@Test
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -1603,6 +1639,24 @@ public class ProjectDetailActivityUiTest {
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test
public void realtimeMessageReloadDoesNotShowSwipeRefreshSpinner() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "juyuwan")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "juyuwan");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "apiClient", new SlowProjectMessagesApiClient());
activity.triggerRealtimeReload(false);
Shadows.shadowOf(activity.getMainLooper()).idle();
SwipeRefreshLayout refreshLayout = activity.findViewById(R.id.screen_refresh_layout);
assertFalse(refreshLayout.isRefreshing());
}
@Test
public void masterAgentHeaderUsesWechatMoreMenuLabel() {
Intent intent = new Intent()
@@ -2458,6 +2512,30 @@ public class ProjectDetailActivityUiTest {
}
}
private static final class SlowProjectMessagesApiClient extends BossApiClient {
SlowProjectMessagesApiClient() {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
}
@Override
public ApiResponse getProjectMessages(String projectId) throws java.io.IOException, org.json.JSONException {
try {
Thread.sleep(250L);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
}
return new ApiResponse(
200,
new JSONObject()
.put("ok", true)
.put("project", new JSONObject()
.put("id", projectId)
.put("name", "juyuwan")
.put("messages", new JSONArray()))
);
}
}
private static final class RecordingConversationActionApiClient extends BossApiClient {
int markConversationReadCount;
String lastMarkedProjectId;

View File

@@ -1,10 +1,15 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.json.JSONObject;
import org.json.JSONArray;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
@@ -68,6 +73,61 @@ public class SkillInventoryActivityTest {
assertEquals(0, activity.reloadCount);
}
@Test
public void renderSkillsShowsManagementDispatchWorkspaceWhenLifecycleRequestsAreAvailable() throws Exception {
Intent intent = new Intent()
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, "Mac Studio");
TestSkillInventoryActivity activity = Robolectric
.buildActivity(TestSkillInventoryActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderSkills",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildSkillPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildLifecyclePayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "Skill 管理分发"));
assertTrue(viewTreeContainsText(content, "安装远端 Skill"));
assertTrue(viewTreeContainsText(content, "分配权限"));
assertTrue(viewTreeContainsText(content, "Skill 请求状态"));
assertTrue(viewTreeContainsText(content, "更新下发"));
assertTrue(viewTreeContainsText(content, "版本锁定"));
}
private static JSONObject buildSkillPayload() throws Exception {
return new JSONObject()
.put("device", new JSONObject()
.put("id", "device-1")
.put("name", "Mac Studio"))
.put("skills", new JSONArray()
.put(new JSONObject()
.put("skillId", "device-1:boss-server-debug")
.put("name", "boss-server-debug")
.put("description", "服务器排障")
.put("category", "ops")
.put("path", "/Users/kris/.codex/skills/boss-server-debug/SKILL.md")
.put("invocation", "使用 boss-server-debug")
.put("updatedAt", "2026-06-08T10:00:00+08:00")));
}
private static JSONObject buildLifecyclePayload() throws Exception {
return new JSONObject()
.put("ok", true)
.put("requests", new JSONArray()
.put(new JSONObject()
.put("requestId", "skill-request-1")
.put("action", "update")
.put("status", "queued")
.put("deviceId", "device-1")
.put("skillId", "device-1:boss-server-debug")
.put("requestedAt", "2026-06-08T10:01:00+08:00")));
}
public static class TestSkillInventoryActivity extends SkillInventoryActivity {
private boolean reloadEnabled;
private int reloadCount;
@@ -81,4 +141,23 @@ public class SkillInventoryActivityTest {
setRefreshing(false);
}
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
}