feat: restore dispatch confirmation flows
This commit is contained in:
@@ -102,6 +102,7 @@ Android APK:
|
||||
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
|
||||
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
|
||||
- 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总
|
||||
- 当前 Web 群聊页也已补上待确认推荐的刷新恢复:群聊详情会在服务端读取最近一条 `pending_user_confirmation` 的 dispatch plan,并在刷新或重新进入页面后继续显示“等待你确认主 Agent 推荐”
|
||||
- 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口
|
||||
- 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入`
|
||||
- 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总
|
||||
|
||||
@@ -90,6 +90,23 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
|
||||
}
|
||||
|
||||
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
|
||||
}
|
||||
|
||||
public ApiResponse confirmDispatchPlan(String projectId, String planId, JSONArray approvedTargetProjectIds) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put(
|
||||
"approvedTargetProjectIds",
|
||||
approvedTargetProjectIds == null ? new JSONArray() : approvedTargetProjectIds
|
||||
);
|
||||
return requestWithRestore(
|
||||
"POST",
|
||||
"/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/confirm",
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("name", name);
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -215,6 +218,73 @@ public final class ProjectChatUiState {
|
||||
return "文件";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JSONObject latestPendingDispatchPlan(@Nullable JSONArray plans) {
|
||||
if (plans == null || plans.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < plans.length(); i++) {
|
||||
JSONObject plan = plans.optJSONObject(i);
|
||||
if (plan == null) {
|
||||
continue;
|
||||
}
|
||||
if ("pending_user_confirmation".equals(plan.optString("status", ""))) {
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<String> dispatchPlanApprovedTargetIds(@Nullable JSONObject plan) {
|
||||
ArrayList<String> approved = new ArrayList<>();
|
||||
if (plan == null) {
|
||||
return approved;
|
||||
}
|
||||
JSONArray targets = plan.optJSONArray("targets");
|
||||
if (targets == null) {
|
||||
return approved;
|
||||
}
|
||||
for (int i = 0; i < targets.length(); i++) {
|
||||
JSONObject target = targets.optJSONObject(i);
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
String projectId = target.optString("projectId", "").trim();
|
||||
if (!projectId.isEmpty()) {
|
||||
approved.add(projectId);
|
||||
}
|
||||
}
|
||||
return approved;
|
||||
}
|
||||
|
||||
public static String summarizeDispatchPlan(@Nullable JSONObject plan) {
|
||||
if (plan == null) {
|
||||
return "主 Agent 暂未生成推荐线程。";
|
||||
}
|
||||
String summary = plan.optString("summary", "").trim();
|
||||
List<String> targetTitles = new ArrayList<>();
|
||||
JSONArray targets = plan.optJSONArray("targets");
|
||||
if (targets != null) {
|
||||
for (int i = 0; i < targets.length(); i++) {
|
||||
JSONObject target = targets.optJSONObject(i);
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
String title = target.optString("threadDisplayName", "").trim();
|
||||
if (!title.isEmpty()) {
|
||||
targetTitles.add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(isBlank(summary) ? "主 Agent 已生成推荐线程。" : summary);
|
||||
if (!targetTitles.isEmpty()) {
|
||||
builder.append("\n推荐线程:");
|
||||
builder.append(String.join("、", targetTitles));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public static String formatAttachmentSize(long fileSizeBytes) {
|
||||
if (fileSizeBytes >= 1024L * 1024L) {
|
||||
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));
|
||||
|
||||
@@ -59,6 +59,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private boolean conversationInfoReady;
|
||||
private String currentScreenTitle;
|
||||
private String currentScreenSubtitle;
|
||||
private String projectCollaborationMode = "development";
|
||||
private String projectApprovalState = "not_required";
|
||||
private @Nullable JSONObject currentPendingDispatchPlan;
|
||||
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
||||
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
||||
private ActivityResultLauncher<Intent> forwardTargetLauncher;
|
||||
@@ -216,7 +219,20 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> renderProject(response.json));
|
||||
JSONObject project = response.json.optJSONObject("project");
|
||||
JSONArray dispatchPlans = null;
|
||||
if (project != null && project.optBoolean("isGroup", false)) {
|
||||
try {
|
||||
BossApiClient.ApiResponse dispatchPlansResponse = apiClient.getDispatchPlans(projectId);
|
||||
if (dispatchPlansResponse.ok()) {
|
||||
dispatchPlans = dispatchPlansResponse.json.optJSONArray("plans");
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
dispatchPlans = null;
|
||||
}
|
||||
}
|
||||
JSONArray finalDispatchPlans = dispatchPlans;
|
||||
runOnUiThread(() -> renderProject(response.json, finalDispatchPlans));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -245,7 +261,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
updateSelectionUi();
|
||||
}
|
||||
|
||||
private void renderProject(JSONObject payload) {
|
||||
private void renderProject(JSONObject payload, @Nullable JSONArray dispatchPlans) {
|
||||
JSONObject project = payload.optJSONObject("project");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
|
||||
@@ -254,12 +270,18 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
initialProjectName = title;
|
||||
projectIsGroup = project != null && project.optBoolean("isGroup", false);
|
||||
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
projectCollaborationMode = project == null ? "development" : project.optString("collaborationMode", "development");
|
||||
projectApprovalState = project == null ? "not_required" : project.optString("approvalState", "not_required");
|
||||
currentPendingDispatchPlan = ProjectChatUiState.latestPendingDispatchPlan(dispatchPlans);
|
||||
conversationInfoReady = project != null;
|
||||
updateProjectHeader(title, buildProjectSubtitle(projectFolderName, devices));
|
||||
|
||||
renderQuickActions();
|
||||
replaceContent();
|
||||
pendingOutgoingBubble = null;
|
||||
if (currentPendingDispatchPlan != null) {
|
||||
appendContent(buildPendingDispatchPlanView(currentPendingDispatchPlan));
|
||||
}
|
||||
|
||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||
selectionState = ProjectChatUiState.reconcileSelection(selectionState, collectMessageIds(messages));
|
||||
@@ -437,11 +459,29 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
JSONObject dispatchPlan = response.json.optJSONObject("dispatchPlan");
|
||||
JSONObject collaborationGate = response.json.optJSONObject("collaborationGate");
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
composerInput.setText("");
|
||||
showMessage("消息已发送");
|
||||
if (collaborationGate != null) {
|
||||
projectCollaborationMode = collaborationGate.optString("collaborationMode", projectCollaborationMode);
|
||||
projectApprovalState = collaborationGate.optString("approvalState", projectApprovalState);
|
||||
}
|
||||
currentPendingDispatchPlan = dispatchPlan;
|
||||
if (dispatchPlan != null) {
|
||||
showMessage(
|
||||
"approval_required".equals(projectCollaborationMode)
|
||||
? "消息已发送,等待你批准主 Agent 下发。"
|
||||
: "消息已发送,主 Agent 已给出推荐线程。"
|
||||
);
|
||||
} else {
|
||||
showMessage("消息已发送");
|
||||
}
|
||||
reload(true);
|
||||
if (dispatchPlan != null) {
|
||||
showDispatchPlanConfirmation(dispatchPlan);
|
||||
}
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
@@ -530,6 +570,74 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
conversationInfoLauncher.launch(intent);
|
||||
}
|
||||
|
||||
private View buildPendingDispatchPlanView(JSONObject dispatchPlan) {
|
||||
LinearLayout container = new LinearLayout(this);
|
||||
container.setOrientation(LinearLayout.VERTICAL);
|
||||
container.addView(BossUi.buildCard(
|
||||
this,
|
||||
"approval_required".equals(projectCollaborationMode) ? "等待你批准主 Agent 下发" : "主 Agent 推荐下发",
|
||||
ProjectChatUiState.summarizeDispatchPlan(dispatchPlan),
|
||||
"当前确认状态:" + projectApprovalState
|
||||
));
|
||||
Button confirmButton = BossUi.buildMiniActionButton(this, "确认下发", true);
|
||||
confirmButton.setOnClickListener(v -> showDispatchPlanConfirmation(dispatchPlan));
|
||||
container.addView(BossUi.buildInlineActionRow(this, confirmButton));
|
||||
return container;
|
||||
}
|
||||
|
||||
private void showDispatchPlanConfirmation(JSONObject dispatchPlan) {
|
||||
String title = "approval_required".equals(projectCollaborationMode)
|
||||
? "批准主 Agent 下发"
|
||||
: "确认主 Agent 推荐";
|
||||
String message = ProjectChatUiState.summarizeDispatchPlan(dispatchPlan)
|
||||
+ "\n\n确认后会把任务下发到推荐线程,并把线程原始回复与主 Agent 汇总一起回到群聊。";
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setNegativeButton("稍后", null)
|
||||
.setPositiveButton("确认下发", (dialog, which) -> confirmDispatchPlan(dispatchPlan))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void confirmDispatchPlan(JSONObject dispatchPlan) {
|
||||
String planId = dispatchPlan.optString("planId", "").trim();
|
||||
if (planId.isEmpty()) {
|
||||
showMessage("缺少调度方案 ID");
|
||||
return;
|
||||
}
|
||||
List<String> approvedTargetProjectIds = ProjectChatUiState.dispatchPlanApprovedTargetIds(dispatchPlan);
|
||||
if (approvedTargetProjectIds.isEmpty()) {
|
||||
showMessage("当前没有可下发的目标线程");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONArray approved = new JSONArray();
|
||||
for (String approvedTargetProjectId : approvedTargetProjectIds) {
|
||||
approved.put(approvedTargetProjectId);
|
||||
}
|
||||
BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan(projectId, planId, approved);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
JSONArray executions = response.json.optJSONArray("executions");
|
||||
int executionCount = executions == null ? approvedTargetProjectIds.size() : executions.length();
|
||||
runOnUiThread(() -> {
|
||||
currentPendingDispatchPlan = null;
|
||||
projectApprovalState = "approval_required".equals(projectCollaborationMode) ? "approved" : "not_required";
|
||||
showMessage("已确认下发到 " + executionCount + " 个线程");
|
||||
reload(true);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("确认下发失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private View buildMessageView(JSONObject message) {
|
||||
String messageId = message.optString("id", "");
|
||||
String senderLabel = message.optString("senderLabel", "消息");
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossApiClientDispatchPlansTest {
|
||||
@Test
|
||||
public void getDispatchPlansUsesProjectScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getDispatchPlans("p1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/p1/dispatch-plans", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan("p1", "plan-1", new JSONArray().put("target-1").put("target-2"));
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/confirm", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody());
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// no-op for JVM unit test
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
private String requestMethodValue = "GET";
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> defValues) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return values.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor() {
|
||||
@Override
|
||||
public Editor putString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
values.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor clear() {
|
||||
values.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putStringSet(String key, Set<String> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putInt(String key, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putLong(String key, long value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putFloat(String key, float value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putBoolean(String key, boolean value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,18 @@ import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ProjectChatUiStateTest {
|
||||
@Test
|
||||
public void sendEnabled_requiresTextAndNotBusy() {
|
||||
@@ -158,4 +165,33 @@ public class ProjectChatUiStateTest {
|
||||
assertTrue(summary.endsWith("…"));
|
||||
assertTrue(summary.contains("这是一条很长很长很长的转发消息摘要"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dispatchPlanSummaryShowsRecommendedTargetNames() throws Exception {
|
||||
JSONObject plan = new JSONObject()
|
||||
.put("summary", "主 Agent 建议先同步 UI 和设备线程")
|
||||
.put("targets", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "p1")
|
||||
.put("threadDisplayName", "Boss UI 主线程"))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "p2")
|
||||
.put("threadDisplayName", "设备接入线程")));
|
||||
|
||||
String summary = ProjectChatUiState.summarizeDispatchPlan(plan);
|
||||
|
||||
assertEquals("主 Agent 建议先同步 UI 和设备线程\n推荐线程:Boss UI 主线程、设备接入线程", summary);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dispatchPlanApprovedTargetIdsFollowRecommendedOrder() throws Exception {
|
||||
JSONObject plan = new JSONObject()
|
||||
.put("targets", new JSONArray()
|
||||
.put(new JSONObject().put("projectId", "p2").put("threadDisplayName", "设备接入线程"))
|
||||
.put(new JSONObject().put("projectId", "p1").put("threadDisplayName", "Boss UI 主线程")));
|
||||
|
||||
List<String> approvedTargetIds = ProjectChatUiState.dispatchPlanApprovedTargetIds(plan);
|
||||
|
||||
assertEquals(List.of("p2", "p1"), approvedTargetIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@
|
||||
- 当前行为:
|
||||
- 只返回当前群聊关联的 dispatch plan
|
||||
- 会附带目标线程列表、审批状态、已确认目标和最近一次确认人
|
||||
- Web/原生前台会用它恢复“等待你确认主 Agent 推荐”的待处理状态;当前 Web 群聊详情页在刷新后会继续渲染最近一条 `pending_user_confirmation` 计划
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm`
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ cd /Users/kris/code/boss
|
||||
- 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent` 或 `审计对话`
|
||||
- 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目
|
||||
- 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总
|
||||
- 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口
|
||||
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
|
||||
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
|
||||
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { listDispatchPlansByProject, readState } from "@/lib/boss-data";
|
||||
import { latestPendingDispatchPlan } from "@/lib/dispatch-plan-ui";
|
||||
import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,6 +26,9 @@ export default async function ProjectChatPage({
|
||||
const { projectId } = await params;
|
||||
const state = await readState();
|
||||
const detail = getProjectDetailView(state, projectId);
|
||||
const pendingDispatchPlan = detail?.project.isGroup
|
||||
? latestPendingDispatchPlan(await listDispatchPlansByProject(projectId))
|
||||
: null;
|
||||
|
||||
if (!detail) notFound();
|
||||
|
||||
@@ -147,7 +151,21 @@ export default async function ProjectChatPage({
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ChatComposer projectId={detail.project.id} />
|
||||
<ChatComposer
|
||||
projectId={detail.project.id}
|
||||
initialPendingDispatchPlan={
|
||||
pendingDispatchPlan
|
||||
? {
|
||||
planId: pendingDispatchPlan.planId,
|
||||
summary: pendingDispatchPlan.summary,
|
||||
targets: (pendingDispatchPlan.targets ?? []).map((target) => ({
|
||||
projectId: target.projectId,
|
||||
threadDisplayName: target.threadDisplayName,
|
||||
})),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
popAppHistoryEntry,
|
||||
resolveAppBackAction,
|
||||
} from "@/lib/boss-app-client";
|
||||
import {
|
||||
extractApprovedTargetProjectIds,
|
||||
summarizeDispatchPlan,
|
||||
} from "@/lib/dispatch-plan-ui";
|
||||
import type {
|
||||
Device,
|
||||
DeviceEnrollment,
|
||||
@@ -806,11 +810,64 @@ export function MasterIdentityPill({ identity }: { identity: MasterIdentitySumma
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatComposer({ projectId }: { projectId: string }) {
|
||||
type PendingDispatchPlanState = {
|
||||
planId: string;
|
||||
summary?: string;
|
||||
targets: Array<{ projectId: string; threadDisplayName: string }>;
|
||||
};
|
||||
|
||||
export function ChatComposer({
|
||||
projectId,
|
||||
initialPendingDispatchPlan,
|
||||
}: {
|
||||
projectId: string;
|
||||
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageTone, setMessageTone] = useState<"success" | "error">("success");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localPendingDispatchPlan, setLocalPendingDispatchPlan] =
|
||||
useState<PendingDispatchPlanState | null>(null);
|
||||
const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState<string | null>(null);
|
||||
const pendingDispatchPlan =
|
||||
localPendingDispatchPlan ??
|
||||
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
|
||||
? initialPendingDispatchPlan
|
||||
: null);
|
||||
|
||||
async function confirmDispatchPlan() {
|
||||
if (!pendingDispatchPlan) return;
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/confirm`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
approvedTargetProjectIds: extractApprovedTargetProjectIds(pendingDispatchPlan),
|
||||
}),
|
||||
},
|
||||
);
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
executions?: Array<unknown>;
|
||||
message?: string;
|
||||
};
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setMessageTone("error");
|
||||
setMessage(result.message ?? "确认下发失败,请重试。");
|
||||
return;
|
||||
}
|
||||
const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length;
|
||||
setLocalPendingDispatchPlan(null);
|
||||
setDismissedPendingPlanId(pendingDispatchPlan.planId);
|
||||
setMessageTone("success");
|
||||
setMessage(`已确认下发到 ${executionCount} 个线程。`);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
|
||||
setLoading(true);
|
||||
@@ -819,7 +876,19 @@ export function ChatComposer({ projectId }: { projectId: string }) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: { body: string } };
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message?: { body: string };
|
||||
dispatchPlan?: {
|
||||
planId: string;
|
||||
summary?: string;
|
||||
targets?: Array<{ projectId: string; threadDisplayName: string }>;
|
||||
} | null;
|
||||
collaborationGate?: {
|
||||
requiresMasterAgentApproval?: boolean;
|
||||
};
|
||||
messageText?: string;
|
||||
};
|
||||
setLoading(false);
|
||||
if (result.ok) {
|
||||
void sendAppLog({
|
||||
@@ -832,7 +901,23 @@ export function ChatComposer({ projectId }: { projectId: string }) {
|
||||
mirrorToMaster: false,
|
||||
});
|
||||
setValue("");
|
||||
setMessage("");
|
||||
if (result.dispatchPlan) {
|
||||
setLocalPendingDispatchPlan({
|
||||
planId: result.dispatchPlan.planId,
|
||||
summary: result.dispatchPlan.summary,
|
||||
targets: result.dispatchPlan.targets ?? [],
|
||||
});
|
||||
setDismissedPendingPlanId(null);
|
||||
setMessage(
|
||||
result.collaborationGate?.requiresMasterAgentApproval
|
||||
? "消息已发送,等待你批准主 Agent 下发。"
|
||||
: "消息已发送,主 Agent 已给出推荐线程。",
|
||||
);
|
||||
setMessageTone("success");
|
||||
} else {
|
||||
setLocalPendingDispatchPlan(null);
|
||||
setMessage("");
|
||||
}
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
@@ -845,6 +930,7 @@ export function ChatComposer({ projectId }: { projectId: string }) {
|
||||
detail: "Boss 会话消息接口返回失败。",
|
||||
mirrorToMaster: true,
|
||||
});
|
||||
setMessageTone("error");
|
||||
setMessage("消息发送失败,请重试。");
|
||||
}
|
||||
|
||||
@@ -887,10 +973,44 @@ export function ChatComposer({ projectId }: { projectId: string }) {
|
||||
<Link href={`/conversations/${projectId}/forward`}>转发</Link>
|
||||
</div>
|
||||
{message ? (
|
||||
<div className="mt-3 rounded-2xl bg-[#FFF1F0] px-4 py-3 text-[12px] text-[#CF1322]">
|
||||
<div
|
||||
className={clsx(
|
||||
"mt-3 rounded-2xl px-4 py-3 text-[12px]",
|
||||
messageTone === "success"
|
||||
? "bg-[#EAF7F0] text-[#215B39]"
|
||||
: "bg-[#FFF1F0] text-[#CF1322]",
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
{pendingDispatchPlan ? (
|
||||
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-4 text-[12px] leading-6 text-[#57606A]">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">等待你确认主 Agent 推荐</div>
|
||||
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(pendingDispatchPlan)}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => void confirmDispatchPlan()}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
|
||||
>
|
||||
确认下发
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setLocalPendingDispatchPlan(null);
|
||||
setDismissedPendingPlanId(pendingDispatchPlan.planId);
|
||||
}}
|
||||
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A]"
|
||||
>
|
||||
稍后处理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4058,6 +4058,9 @@ function upsertDispatchPlanInState(
|
||||
if (!groupProjectId) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_REQUIRED");
|
||||
if (!requestMessageId) throw new Error("DISPATCH_PLAN_REQUEST_MESSAGE_REQUIRED");
|
||||
if (!requestedBy) throw new Error("DISPATCH_PLAN_REQUESTED_BY_REQUIRED");
|
||||
const groupProject = state.projects.find((item) => item.id === groupProjectId);
|
||||
if (!groupProject) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_NOT_FOUND");
|
||||
if (!groupProject.isGroup) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_INVALID");
|
||||
|
||||
const validatedTargets = normalizeDispatchPlanTargetsForCreate(state, input.targets);
|
||||
const existing = state.dispatchPlans.find(
|
||||
@@ -4071,6 +4074,9 @@ function upsertDispatchPlanInState(
|
||||
if (!payloadMatches) {
|
||||
throw new Error("DISPATCH_PLAN_RETRY_MISMATCH");
|
||||
}
|
||||
if (groupProject.collaborationMode === "approval_required") {
|
||||
groupProject.approvalState = "pending_user";
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -4085,6 +4091,11 @@ function upsertDispatchPlanInState(
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
state.dispatchPlans.unshift(plan);
|
||||
if (groupProject.collaborationMode === "approval_required") {
|
||||
groupProject.approvalState = "pending_user";
|
||||
} else {
|
||||
groupProject.approvalState = "not_required";
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
@@ -4163,6 +4174,8 @@ export async function createDispatchExecutionsFromPlan(input: {
|
||||
if (plan.confirmedBy !== confirmedBy) {
|
||||
throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH");
|
||||
}
|
||||
const groupProject = state.projects.find((item) => item.id === plan.groupProjectId);
|
||||
if (!groupProject) throw new Error("PROJECT_NOT_FOUND");
|
||||
|
||||
const canonicalTargetProjectIds = normalizeStringSet(plan.confirmedTargetProjectIds);
|
||||
const existingExecutions = state.dispatchExecutions.filter((item) => item.planId === plan.planId);
|
||||
@@ -4174,6 +4187,8 @@ export async function createDispatchExecutionsFromPlan(input: {
|
||||
if (plan.status !== "dispatched") {
|
||||
plan.status = "dispatched";
|
||||
}
|
||||
groupProject.approvalState =
|
||||
groupProject.collaborationMode === "approval_required" ? "approved" : "not_required";
|
||||
ensureDispatchExecutionTasksInState(state, plan, existingExecutions);
|
||||
return existingExecutions;
|
||||
}
|
||||
@@ -4201,6 +4216,8 @@ export async function createDispatchExecutionsFromPlan(input: {
|
||||
});
|
||||
ensureDispatchExecutionTasksInState(state, plan, executions);
|
||||
plan.status = "dispatched";
|
||||
groupProject.approvalState =
|
||||
groupProject.collaborationMode === "approval_required" ? "approved" : "not_required";
|
||||
return executions;
|
||||
});
|
||||
}
|
||||
@@ -4344,6 +4361,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
|
||||
if (plan.status !== "dispatched") {
|
||||
plan.status = "dispatched";
|
||||
}
|
||||
groupProject.approvalState =
|
||||
groupProject.collaborationMode === "approval_required" ? "approved" : "not_required";
|
||||
executions = existingExecutions;
|
||||
} else {
|
||||
const targets = plan.targets.filter((target) =>
|
||||
@@ -4368,6 +4387,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
|
||||
return execution;
|
||||
});
|
||||
plan.status = "dispatched";
|
||||
groupProject.approvalState =
|
||||
groupProject.collaborationMode === "approval_required" ? "approved" : "not_required";
|
||||
const targetSummary = executions
|
||||
.map((execution) => {
|
||||
const project = state.projects.find((item) => item.id === execution.targetProjectId);
|
||||
|
||||
35
src/lib/dispatch-plan-ui.ts
Normal file
35
src/lib/dispatch-plan-ui.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type DispatchPlanUiTarget = {
|
||||
projectId: string;
|
||||
threadDisplayName: string;
|
||||
};
|
||||
|
||||
export type DispatchPlanUiPayload = {
|
||||
planId: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
targets?: DispatchPlanUiTarget[];
|
||||
};
|
||||
|
||||
export function latestPendingDispatchPlan(plans: DispatchPlanUiPayload[] | null | undefined) {
|
||||
return (plans ?? []).find((plan) => plan.status === "pending_user_confirmation") ?? null;
|
||||
}
|
||||
|
||||
export function summarizeDispatchPlan(plan: DispatchPlanUiPayload | null | undefined) {
|
||||
if (!plan) {
|
||||
return "主 Agent 暂未生成推荐线程。";
|
||||
}
|
||||
const summary = plan.summary?.trim() || "主 Agent 已生成推荐线程。";
|
||||
const titles = (plan.targets ?? [])
|
||||
.map((target) => target.threadDisplayName?.trim() || "")
|
||||
.filter(Boolean);
|
||||
if (!titles.length) {
|
||||
return summary;
|
||||
}
|
||||
return `${summary}\n推荐线程:${titles.join("、")}`;
|
||||
}
|
||||
|
||||
export function extractApprovedTargetProjectIds(plan: DispatchPlanUiPayload | null | undefined) {
|
||||
return (plan?.targets ?? [])
|
||||
.map((target) => target.projectId?.trim() || "")
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -203,3 +203,38 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms
|
||||
);
|
||||
assert.ok(notice, "expected a master-agent notice in the group chat after confirmation");
|
||||
});
|
||||
|
||||
test("confirming a dispatch plan marks approval_required groups as approved", async () => {
|
||||
const { groupProject, dispatchPlan } = await createDispatchPlanForTest();
|
||||
const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId;
|
||||
assert.ok(approvedTargetProjectId, "expected a recommended target project");
|
||||
|
||||
const state = await readState();
|
||||
await writeState({
|
||||
...state,
|
||||
projects: state.projects.map((project) =>
|
||||
project.id === groupProject.id
|
||||
? {
|
||||
...project,
|
||||
collaborationMode: "approval_required" as const,
|
||||
approvalState: "pending_user" as const,
|
||||
}
|
||||
: project,
|
||||
),
|
||||
});
|
||||
|
||||
const response = await confirmDispatchPlanRoute(
|
||||
await createAuthedRequest(
|
||||
`http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/confirm`,
|
||||
"POST",
|
||||
{ approvedTargetProjectIds: [approvedTargetProjectId] },
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) },
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const nextState = await readState();
|
||||
const nextGroupProject = nextState.projects.find((project) => project.id === groupProject.id);
|
||||
assert.ok(nextGroupProject, "expected group project to remain present");
|
||||
assert.equal(nextGroupProject?.approvalState, "approved");
|
||||
});
|
||||
|
||||
56
tests/dispatch-plan-ui.test.ts
Normal file
56
tests/dispatch-plan-ui.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
extractApprovedTargetProjectIds,
|
||||
latestPendingDispatchPlan,
|
||||
summarizeDispatchPlan,
|
||||
} from "@/lib/dispatch-plan-ui";
|
||||
|
||||
test("summarizeDispatchPlan combines summary and recommended target titles", () => {
|
||||
const summary = summarizeDispatchPlan({
|
||||
planId: "dispatch-plan-0",
|
||||
summary: "主 Agent 建议先同步 UI 和设备线程",
|
||||
targets: [
|
||||
{ projectId: "p1", threadDisplayName: "Boss UI 主线程" },
|
||||
{ projectId: "p2", threadDisplayName: "设备接入线程" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(summary, "主 Agent 建议先同步 UI 和设备线程\n推荐线程:Boss UI 主线程、设备接入线程");
|
||||
});
|
||||
|
||||
test("extractApprovedTargetProjectIds keeps target order and drops blanks", () => {
|
||||
const ids = extractApprovedTargetProjectIds({
|
||||
planId: "dispatch-plan-0",
|
||||
targets: [
|
||||
{ projectId: "p2", threadDisplayName: "设备接入线程" },
|
||||
{ projectId: "p1", threadDisplayName: "Boss UI 主线程" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(ids, ["p2", "p1"]);
|
||||
});
|
||||
|
||||
test("latestPendingDispatchPlan returns the latest waiting confirmation item", () => {
|
||||
const plan = latestPendingDispatchPlan([
|
||||
{
|
||||
planId: "dispatch-plan-1",
|
||||
status: "dispatched",
|
||||
summary: "已完成的推荐",
|
||||
targets: [{ projectId: "p1", threadDisplayName: "Boss UI 主线程" }],
|
||||
},
|
||||
{
|
||||
planId: "dispatch-plan-2",
|
||||
status: "pending_user_confirmation",
|
||||
summary: "等待确认的推荐",
|
||||
targets: [{ projectId: "p2", threadDisplayName: "设备接入线程" }],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(plan, {
|
||||
planId: "dispatch-plan-2",
|
||||
status: "pending_user_confirmation",
|
||||
summary: "等待确认的推荐",
|
||||
targets: [{ projectId: "p2", threadDisplayName: "设备接入线程" }],
|
||||
});
|
||||
});
|
||||
@@ -190,6 +190,60 @@ test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for sin
|
||||
assert.equal(payload.collaborationGate.isGroup, false);
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages marks approval_required groups as pending user approval", async () => {
|
||||
await setup();
|
||||
const memberProjects = await ensureTwoSingleThreadProjects();
|
||||
assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects");
|
||||
|
||||
const groupProject = await createProjectGroupChat({
|
||||
sourceProjectId: memberProjects[0].id,
|
||||
memberProjectIds: [memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
await writeState({
|
||||
...state,
|
||||
projects: state.projects.map((project) =>
|
||||
project.id === groupProject.id
|
||||
? {
|
||||
...project,
|
||||
collaborationMode: "approval_required" as const,
|
||||
approvalState: "not_required" as const,
|
||||
}
|
||||
: project,
|
||||
),
|
||||
});
|
||||
|
||||
const response = await POST(await createAuthedRequest(groupProject.id, { body: "请协调两个线程确认上线方案" }), {
|
||||
params: Promise.resolve({ projectId: groupProject.id }),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
dispatchPlan: { planId: string } | null;
|
||||
collaborationGate: {
|
||||
isGroup: boolean;
|
||||
collaborationMode: "development" | "approval_required";
|
||||
requiresMasterAgentApproval: boolean;
|
||||
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.ok(payload.dispatchPlan, "expected dispatch plan");
|
||||
assert.equal(payload.collaborationGate.isGroup, true);
|
||||
assert.equal(payload.collaborationGate.collaborationMode, "approval_required");
|
||||
assert.equal(payload.collaborationGate.requiresMasterAgentApproval, true);
|
||||
assert.equal(payload.collaborationGate.approvalState, "pending_user");
|
||||
|
||||
const nextState = await readState();
|
||||
const persistedGroup = nextState.projects.find((project) => project.id === groupProject.id);
|
||||
assert.ok(persistedGroup, "expected group project to persist");
|
||||
assert.equal(persistedGroup?.approvalState, "pending_user");
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages keeps message success when group dispatch recommendation fails", async () => {
|
||||
await setup();
|
||||
const memberProjects = await ensureTwoSingleThreadProjects();
|
||||
|
||||
Reference in New Issue
Block a user