Add thread execution conflict guards to chat flows
This commit is contained in:
@@ -81,6 +81,48 @@ public final class ProjectChatUiState {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中冲突保护";
|
||||
}
|
||||
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
|
||||
return "当前项目默认先走 GUI";
|
||||
}
|
||||
return "当前项目已命中并发保护";
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictSummary(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中了 GUI / CLI 冲突保护,请先确认是否继续。";
|
||||
}
|
||||
String projectName = conflict.optString("projectName", "当前项目");
|
||||
String deviceName = conflict.optString("deviceName", "当前设备");
|
||||
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
|
||||
return deviceName + " 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 " + projectName + ",需要你先对这个项目放行;这个选择只对这个项目生效。";
|
||||
}
|
||||
return projectName + " 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。";
|
||||
}
|
||||
|
||||
public static String labelForThreadExecutionConflictDecision(@Nullable String decision) {
|
||||
if ("allow_once".equals(decision)) {
|
||||
return "允许本次";
|
||||
}
|
||||
if ("allow_always".equals(decision)) {
|
||||
return "永久放行";
|
||||
}
|
||||
return "禁止";
|
||||
}
|
||||
|
||||
public static String summarizeThreadExecutionConflictDecisionResult(@Nullable String decision) {
|
||||
if ("allow_once".equals(decision)) {
|
||||
return "已允许本次,继续发送中…";
|
||||
}
|
||||
if ("allow_always".equals(decision)) {
|
||||
return "已对当前项目永久放行,继续发送中…";
|
||||
}
|
||||
return "已保持禁止,这次消息没有发出。";
|
||||
}
|
||||
|
||||
public static SelectionState emptySelection() {
|
||||
return new SelectionState(new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
@@ -649,7 +649,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.sendProjectMessage(projectId, body, kind);
|
||||
JSONObject executionConflict = response.json.optJSONObject("executionConflict");
|
||||
if (!response.ok()) {
|
||||
if (response.statusCode == 409
|
||||
&& "THREAD_EXECUTION_CONFLICT".equals(response.json.optString("code", ""))
|
||||
&& executionConflict != null) {
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
removePendingOutgoingBubble();
|
||||
updateComposerSendButtonState();
|
||||
showThreadExecutionConflictDialog(executionConflict, body, kind);
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
|
||||
@@ -702,6 +715,85 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void showThreadExecutionConflictDialog(JSONObject executionConflict, String body, String kind) {
|
||||
String preferredMode = "gui".equals(executionConflict.optString("preferredExecutionMode", "cli"))
|
||||
? "GUI"
|
||||
: "CLI";
|
||||
String deviceName = executionConflict.optString("deviceName", "当前设备");
|
||||
String folderKey = executionConflict.optString("folderKey", "").trim();
|
||||
StringBuilder messageBuilder = new StringBuilder(ProjectChatUiState.threadExecutionConflictSummary(executionConflict))
|
||||
.append("\n\n设备:")
|
||||
.append(deviceName)
|
||||
.append(" · 默认模式:")
|
||||
.append(preferredMode);
|
||||
if (!folderKey.isEmpty()) {
|
||||
messageBuilder.append("\n范围:").append(folderKey);
|
||||
}
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(ProjectChatUiState.threadExecutionConflictTitle(executionConflict))
|
||||
.setMessage(messageBuilder.toString())
|
||||
.setNegativeButton(
|
||||
ProjectChatUiState.labelForThreadExecutionConflictDecision("forbid"),
|
||||
(dialog, which) -> {
|
||||
showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult("forbid"));
|
||||
updateComposerSendButtonState();
|
||||
}
|
||||
)
|
||||
.setNeutralButton(
|
||||
ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"),
|
||||
(dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_once", body, kind)
|
||||
)
|
||||
.setPositiveButton(
|
||||
ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"),
|
||||
(dialog, which) -> applyThreadExecutionConflictDecision(executionConflict, "allow_always", body, kind)
|
||||
)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void applyThreadExecutionConflictDecision(
|
||||
JSONObject executionConflict,
|
||||
String decision,
|
||||
String body,
|
||||
String kind
|
||||
) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String folderKey = executionConflict.optString("folderKey", "").trim();
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision(
|
||||
executionConflict.optString("deviceId", ""),
|
||||
executionConflict.optString("projectId", ""),
|
||||
folderKey.isEmpty() ? null : folderKey,
|
||||
decision
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage(ProjectChatUiState.summarizeThreadExecutionConflictDecisionResult(decision));
|
||||
if ("forbid".equals(decision)) {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
updateComposerSendButtonState();
|
||||
return;
|
||||
}
|
||||
composerSending = false;
|
||||
updateComposerSendButtonState();
|
||||
sendProjectMessage(kind, body);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
updateComposerSendButtonState();
|
||||
showMessage("冲突放行设置失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
|
||||
@@ -249,4 +249,27 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-thread-1"));
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
|
||||
JSONObject conflict = new JSONObject()
|
||||
.put("projectName", "Boss UI 主线程")
|
||||
.put("deviceName", "Mac Studio")
|
||||
.put("reason", "preferred_gui_mode");
|
||||
|
||||
assertEquals("当前项目默认先走 GUI", ProjectChatUiState.threadExecutionConflictTitle(conflict));
|
||||
assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只对这个项目生效"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadExecutionConflictCopyExplainsForbidAsProjectOnly() throws Exception {
|
||||
JSONObject conflict = new JSONObject()
|
||||
.put("projectName", "Boss UI 主线程")
|
||||
.put("reason", "project_conflict_forbid");
|
||||
|
||||
assertEquals("当前项目已命中并发保护", ProjectChatUiState.threadExecutionConflictTitle(conflict));
|
||||
assertTrue(ProjectChatUiState.threadExecutionConflictSummary(conflict).contains("只影响这个项目"));
|
||||
assertEquals("允许本次", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_once"));
|
||||
assertEquals("永久放行", ProjectChatUiState.labelForThreadExecutionConflictDecision("allow_always"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user