diff --git a/README.md b/README.md index 3c96ffd..7301f9d 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Android APK: - 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk` - 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk` - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` -- 当前最新 release 构建版本:`2.5.4`(`versionCode=17`) +- 当前最新 release 构建版本:`2.5.5`(`versionCode=18`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 @@ -106,9 +106,11 @@ Android APK: - 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页 - 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页 - 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总 +- 当前 `approval_required` 群聊已补齐两条审批动作:可以确认主 Agent 推荐,也可以明确拒绝;拒绝后会把群审批状态写成 `rejected`,并在群里追加系统提示,不会继续下发到线程 - 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示 - 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号` 与 `绑定 Master Codex Node` 两条显式入口;OpenAI API 登录成功后会立即设为当前主控 - 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,避免聊天直接掉成失败日志 +- 当前群资料页已经支持“修复群成员”:如果历史脏群里混入了 `master-agent` 或失效线程引用,前台会明确提示并允许重新选择真实线程成员,修复后会正式写回群成员账本 - 当前 Web 群聊页也已补上待确认推荐的刷新恢复:群聊详情会在服务端读取最近一条 `pending_user_confirmation` 的 dispatch plan,并在刷新或重新进入页面后继续显示“等待你确认主 Agent 推荐” - 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口 - 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入` @@ -129,6 +131,7 @@ Android APK: - `2.5.1` 继续收口微信式原生 UI:聊天页普通态顶部已隐藏刷新按钮,只保留右上角“信息”;发起群聊页顶部说明和选择区已压成更轻的会话式密度,候选线程继续复用微信式会话卡片 - `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级 - `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明 +- `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作 ## 本地启动 diff --git a/android/app/build.gradle b/android/app/build.gradle index c132930..fad6e77 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,8 +36,8 @@ android { applicationId "com.hyzq.boss" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 17 - versionName "2.5.4" + versionCode 18 + versionName "2.5.5" buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index ff32e58..d2b3940 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -112,6 +112,16 @@ public class BossApiClient { ); } + public ApiResponse rejectDispatchPlan(String projectId, String planId) throws IOException, JSONException { + return requestWithRestoreRaw( + "POST", + "/api/v1/projects/" + encode(projectId) + "/dispatch-plans/" + encode(planId) + "/reject", + new JSONObject().toString(), + DEFAULT_CONNECT_TIMEOUT_MS, + CHAT_FLOW_READ_TIMEOUT_MS + ); + } + public ApiResponse renameConversation(String projectId, String name, boolean group) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put("name", name); @@ -131,6 +141,12 @@ public class BossApiClient { return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null); } + public ApiResponse replaceConversationParticipants(String projectId, JSONArray memberProjectIds) throws IOException, JSONException { + JSONObject payload = new JSONObject(); + payload.put("memberProjectIds", memberProjectIds == null ? new JSONArray() : memberProjectIds); + return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/participants", payload); + } + public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException { JSONObject payload = new JSONObject(); payload.put("body", body); diff --git a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java index 5508eb8..7d53fc2 100644 --- a/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java @@ -2,6 +2,7 @@ package com.hyzq.boss; import android.content.Intent; import android.os.Bundle; +import android.text.TextUtils; import android.widget.EditText; import android.widget.LinearLayout; @@ -72,6 +73,10 @@ public class GroupInfoActivity extends BossScreenActivity { JSONObject threadMeta = project.optJSONObject("threadMeta"); String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", ""); int participantCount = participants == null ? 0 : participants.length(); + boolean repairRequired = participantsPayload.optBoolean("repairRequired", false); + String repairReason = participantsPayload.optString("repairReason", ""); + int validParticipantCount = participantsPayload.optInt("validParticipantCount", participantCount); + int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0); configureScreen("群资料", buildSubtitle(folderName, participantCount)); appendContent(BossUi.buildSimpleProfileHeader( @@ -90,6 +95,20 @@ public class GroupInfoActivity extends BossScreenActivity { v -> openProject(projectId, projectName) )); + if (repairRequired) { + String meta = invalidParticipantCount > 0 + ? "存在 " + invalidParticipantCount + " 个失效成员" + : "当前仅有 " + validParticipantCount + " 个真实线程成员"; + appendContent(BossUi.buildWechatMenuRow( + this, + "修复群成员", + TextUtils.isEmpty(repairReason) ? "重新选择要加入群聊的真实线程" : repairReason, + meta, + "推荐", + v -> openRepairMembersDialog(participantsPayload) + )); + } + appendContent(BossUi.buildWechatMenuRow( this, "群成员", @@ -121,6 +140,9 @@ public class GroupInfoActivity extends BossScreenActivity { private LinearLayout buildMemberRow(JSONObject participant) { boolean sourceProject = participant.optBoolean("isSourceProject", false); + boolean canOpenProject = participant.optBoolean("canOpenProject", true); + String status = participant.optString("status", "active"); + String statusLabel = participant.optString("statusLabel", ""); String participantProjectId = participant.optString("projectId", ""); String title = participant.optString("threadDisplayName", "未命名线程"); String subtitle = participant.optString("folderName", ""); @@ -132,16 +154,120 @@ public class GroupInfoActivity extends BossScreenActivity { if (sourceProject) { subtitle = subtitle.isEmpty() ? "当前群聊" : "当前群聊 · " + subtitle; } + if (!statusLabel.isEmpty() && !"active".equals(status)) { + subtitle = subtitle.isEmpty() ? statusLabel : subtitle + " · " + statusLabel; + } return BossUi.buildWechatMenuRow( this, title, subtitle, meta, - sourceProject ? "当前" : null, - v -> openProject(participantProjectId, title) + sourceProject ? "当前" : (!"active".equals(status) ? "失效" : null), + canOpenProject ? v -> openProject(participantProjectId, title) : null ); } + private void openRepairMembersDialog(JSONObject participantsPayload) { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations(); + if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message()); + runOnUiThread(() -> showRepairMembersPicker(participantsPayload, conversationsResponse.json)); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("加载可选线程失败:" + error.getMessage()); + }); + } + }); + } + + private void showRepairMembersPicker(JSONObject participantsPayload, JSONObject conversationsPayload) { + JSONArray conversations = conversationsPayload.optJSONArray("conversations"); + JSONArray participants = participantsPayload.optJSONArray("participants"); + java.util.Set selectedProjectIds = new java.util.LinkedHashSet<>(); + if (participants != null) { + for (int i = 0; i < participants.length(); i++) { + JSONObject participant = participants.optJSONObject(i); + if (participant == null) continue; + if (!"active".equals(participant.optString("status", "active"))) continue; + String participantProjectId = participant.optString("projectId", "").trim(); + if (!participantProjectId.isEmpty()) { + selectedProjectIds.add(participantProjectId); + } + } + } + + java.util.ArrayList labels = new java.util.ArrayList<>(); + java.util.ArrayList projectIds = new java.util.ArrayList<>(); + java.util.ArrayList checkedValues = new java.util.ArrayList<>(); + if (conversations != null) { + for (int i = 0; i < conversations.length(); i++) { + JSONObject conversation = conversations.optJSONObject(i); + if (conversation == null) continue; + if (!"single_device".equals(conversation.optString("conversationType", ""))) continue; + String candidateProjectId = conversation.optString("projectId", "").trim(); + if (candidateProjectId.isEmpty() || candidateProjectId.equals(projectId)) continue; + String title = conversation.optString("threadTitle", conversation.optString("projectTitle", "未命名线程")); + String folderLabel = conversation.optString("folderLabel", ""); + labels.add(TextUtils.isEmpty(folderLabel) ? title : title + " · " + folderLabel); + projectIds.add(candidateProjectId); + checkedValues.add(selectedProjectIds.contains(candidateProjectId)); + } + } + + if (labels.isEmpty()) { + setRefreshing(false); + showMessage("当前没有可加入群聊的真实线程"); + return; + } + + CharSequence[] items = labels.toArray(new CharSequence[0]); + boolean[] checked = new boolean[checkedValues.size()]; + for (int i = 0; i < checked.length; i++) { + checked[i] = checkedValues.get(i); + } + + setRefreshing(false); + new AlertDialog.Builder(this) + .setTitle("修复群成员") + .setMessage("请选择要加入这个群聊的真实线程。") + .setMultiChoiceItems(items, checked, (dialog, which, isChecked) -> checked[which] = isChecked) + .setNegativeButton("取消", null) + .setPositiveButton("应用", (dialog, which) -> applyGroupRepair(projectIds, checked)) + .show(); + } + + private void applyGroupRepair(java.util.List candidateProjectIds, boolean[] checked) { + JSONArray memberProjectIds = new JSONArray(); + for (int i = 0; i < checked.length && i < candidateProjectIds.size(); i++) { + if (checked[i]) { + memberProjectIds.put(candidateProjectIds.get(i)); + } + } + if (memberProjectIds.length() < 2) { + showMessage("群聊至少需要 2 个真实线程成员"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds); + if (!response.ok()) throw new IllegalStateException(response.message()); + runOnUiThread(() -> { + showMessage("群成员已更新"); + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("修复失败:" + error.getMessage()); + }); + } + }); + } + private void openProject(String targetProjectId, String targetProjectName) { if (targetProjectId == null || targetProjectId.isEmpty()) { showMessage("缺少 projectId"); diff --git a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java index e080fe7..ad7066f 100644 --- a/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java @@ -598,7 +598,8 @@ public class ProjectDetailActivity extends BossScreenActivity { new AlertDialog.Builder(this) .setTitle(title) .setMessage(message) - .setNegativeButton("稍后", null) + .setNegativeButton("拒绝", (dialog, which) -> rejectDispatchPlan(dispatchPlan)) + .setNeutralButton("稍后", null) .setPositiveButton("确认下发", (dialog, which) -> confirmDispatchPlan(dispatchPlan)) .show(); } @@ -651,6 +652,36 @@ public class ProjectDetailActivity extends BossScreenActivity { }); } + private void rejectDispatchPlan(JSONObject dispatchPlan) { + String planId = dispatchPlan.optString("planId", "").trim(); + if (planId.isEmpty()) { + showMessage("缺少调度方案 ID"); + return; + } + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.rejectDispatchPlan(projectId, planId); + if (!response.ok()) { + throw new IllegalStateException(response.message()); + } + runOnUiThread(() -> { + currentPendingDispatchPlan = null; + composerSending = false; + projectApprovalState = "rejected"; + updateComposerSendButtonState(); + showMessage("已拒绝主 Agent 推荐"); + 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", "消息"); diff --git a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java index 158fca7..3818e61 100644 --- a/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java +++ b/android/app/src/test/java/com/hyzq/boss/BossApiClientDispatchPlansTest.java @@ -54,6 +54,21 @@ public class BossApiClientDispatchPlansTest { assertEquals("{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"]}", connection.requestBody()); } + @Test + public void rejectDispatchPlanUsesProjectScopedRejectEndpoint() throws Exception { + RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/reject")); + RecordingBossApiClient apiClient = new RecordingBossApiClient(connection); + + BossApiClient.ApiResponse response = apiClient.rejectDispatchPlan("p1", "plan-1"); + + assertEquals(200, response.statusCode); + assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/reject", apiClient.lastPath); + assertEquals("POST", connection.requestMethodValue); + assertEquals(12000, connection.connectTimeoutValue); + assertEquals(65000, connection.readTimeoutValue); + assertEquals("{}", connection.requestBody()); + } + @Test public void sendProjectMessageUsesExtendedReadTimeoutForMasterAgent() throws Exception { RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages")); diff --git a/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java index 211fc25..8264947 100644 --- a/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/GroupInfoActivityTest.java @@ -92,6 +92,29 @@ public class GroupInfoActivityTest { ); } + @Test + public void renderGroupShowsRepairEntryForDirtyMembers() throws Exception { + Intent intent = new Intent() + .putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1") + .putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群"); + TestGroupInfoActivity activity = Robolectric + .buildActivity(TestGroupInfoActivity.class, intent) + .setup() + .get(); + + ReflectionHelpers.callInstanceMethod( + activity, + "renderGroup", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()), + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildRepairParticipantsPayload()) + ); + + LinearLayout content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "修复群成员")); + assertTrue(viewTreeContainsText(content, "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。")); + assertTrue(viewTreeContainsText(content, "失效")); + } + private static JSONObject buildDetailPayload() throws Exception { JSONObject threadMeta = new JSONObject() .put("threadId", "group-thread-3") @@ -123,6 +146,25 @@ public class GroupInfoActivityTest { return new JSONObject().put("participants", participants); } + private static JSONObject buildRepairParticipantsPayload() throws Exception { + JSONArray participants = new JSONArray() + .put(new JSONObject() + .put("projectId", "master-agent") + .put("threadDisplayName", "主 Agent 汇总") + .put("folderName", "主控线程") + .put("deviceId", "Mac Studio") + .put("threadId", "master-agent-thread") + .put("status", "invalid_target") + .put("statusLabel", "不是可下发线程") + .put("canOpenProject", true)); + return new JSONObject() + .put("participants", participants) + .put("repairRequired", true) + .put("repairReason", "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。") + .put("validParticipantCount", 0) + .put("invalidParticipantCount", 1); + } + private static boolean viewTreeContainsText(View root, String expectedText) { if (root instanceof TextView) { CharSequence text = ((TextView) root).getText(); diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 71a0945..e5ea8d7 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -347,6 +347,20 @@ - `isGroup` - `threadMeta` - `participants[]` + - `repairRequired` + - `repairReason` + - `validParticipantCount` + - `invalidParticipantCount` + +#### `POST /api/v1/projects/[projectId]/participants` + +- 用途:修复或重设群聊成员 +- 输入: + - `memberProjectIds[]` +- 当前行为: + - 只允许把真实可下发的线程会话加入群聊 + - 至少要求 2 个线程成员 + - 成功后会重写群成员列表,并追加一条 `kind=system_notice` 的“已更新群成员”消息 #### `POST /api/v1/projects/[projectId]/rename` @@ -398,6 +412,14 @@ - 会写入一条 `kind=system_notice` 的群聊系统消息 - 同时会为每个执行单创建 `taskType=dispatch_execution` 的主 Agent 任务,等待对应设备的 local-agent 认领 +#### `POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/reject` + +- 用途:拒绝主 Agent 推荐的线程下发方案 +- 当前行为: + - 会把 dispatch plan 标记为 `rejected` + - 对 `approval_required` 群聊会把项目审批状态写成 `rejected` + - 会追加一条 `kind=system_notice` 的“已拒绝主 Agent 推荐”消息 + #### `GET /api/v1/accounts` - 用途:返回 AI 账号列表、当前主控身份和切换历史 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 283467b..f23b8b6 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -102,12 +102,14 @@ cd /Users/kris/code/boss - 原生转发目标页当前统一由 `ForwardTargetActivity` 承接;一次只允许选择一个目标会话,目标可为单线程会话、群聊、`主 Agent` 或 `审计对话` - 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目 - 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总 +- 当前 `approval_required` 群聊已补齐“确认 / 拒绝”两条审批动作:确认后才会创建 `dispatchExecution`,拒绝后会把群审批状态写成 `rejected`,并在群里追加明确系统提示 - 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口 - 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口 - 当前 `AI 账号` 页面已分成两条显式接入链:`登录 OpenAI 平台账号(API Key)` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控 - 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API` 备用账号,不再把失败日志直接原样回给用户 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 +- 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口 - 当前 `dispatch_execution` 完成回写已补幂等,重复完成同一个线程执行单不会再重复向群聊追加线程原始回复和主 Agent 汇总 - 当前原生 Android 已把三条聊天主链统一成等待真实回写:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 都会保持等待,直到收到实际回复或明确超时提示 @@ -139,7 +141,7 @@ cd /Users/kris/code/boss - 当前已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk` - 当前已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk` - 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` -- 当前最新 release 构建版本:`2.5.4`(`versionCode=17`) +- 当前最新 release 构建版本:`2.5.5`(`versionCode=18`) - 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties` - `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退 - `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity` @@ -157,6 +159,8 @@ cd /Users/kris/code/boss - `2.5.2` 已压缩附件消息卡片的状态层级:`待分析` 收成 `可分析`,`让 AI 分析` 收成 `AI 分析`,有摘要时不再重复显示 `已分析` - `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,会话根页同步改成更扁平的白底聊天列表,不再是厚圆角卡片流 - `2.5.4` 已把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从绿色 `soft panel` 降成轻量列表说明,和会话/设备页统一成同一套微信式产品语言 +- `2.5.5` 已补上群资料页“修复群成员”主链:历史脏群现在会明确提示失效成员,并允许重新选择真实线程成员写回群资料 +- `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程 - 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net` - `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理 - 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列 diff --git a/public/downloads/boss-android-latest-aab.json b/public/downloads/boss-android-latest-aab.json index 79d852a..2c67c17 100644 --- a/public/downloads/boss-android-latest-aab.json +++ b/public/downloads/boss-android-latest-aab.json @@ -1,11 +1,11 @@ { "artifactType": "aab", - "fileName": "boss-android-v2.5.4-release.aab", - "urlPath": "/downloads/boss-android-v2.5.4-release.aab", - "sizeBytes": 2912332, - "updatedAt": "2026-03-30T04:24:49Z", - "sha256": "75d622b5b48ca5b6005406202ea4b016d64cc7049535e89a38e8cb127ca682e8", - "versionName": "2.5.4", - "versionCode": 17, + "fileName": "boss-android-v2.5.5-release.aab", + "urlPath": "/downloads/boss-android-v2.5.5-release.aab", + "sizeBytes": 2928015, + "updatedAt": "2026-03-30T19:53:34Z", + "sha256": "1178102370885d5a2e43ca177daf09d1f2416aa36c2f6312c3a04e4158cb4b44", + "versionName": "2.5.5", + "versionCode": 18, "buildFlavor": "release" } diff --git a/public/downloads/boss-android-latest.aab b/public/downloads/boss-android-latest.aab index abfe7f8..fa5ea2b 100644 Binary files a/public/downloads/boss-android-latest.aab and b/public/downloads/boss-android-latest.aab differ diff --git a/public/downloads/boss-android-latest.apk b/public/downloads/boss-android-latest.apk index 7b14761..8816e80 100644 Binary files a/public/downloads/boss-android-latest.apk and b/public/downloads/boss-android-latest.apk differ diff --git a/public/downloads/boss-android-latest.json b/public/downloads/boss-android-latest.json index 0a9d2fe..dcedea4 100644 --- a/public/downloads/boss-android-latest.json +++ b/public/downloads/boss-android-latest.json @@ -1,10 +1,10 @@ { - "fileName": "boss-android-v2.5.4-release.apk", + "fileName": "boss-android-v2.5.5-release.apk", "urlPath": "/api/v1/user/ota/package", - "sizeBytes": 3089082, - "updatedAt": "2026-03-30T04:20:41Z", - "sha256": "a76f6a86c0923695188750a8794d1ded9defa5e9f3bf898b24421c9cae435b02", - "versionName": "2.5.4", - "versionCode": 17, + "sizeBytes": 3104744, + "updatedAt": "2026-03-30T19:53:27Z", + "sha256": "c8ab4c48d17504c0ac55b621ef6623940b0da73a59f079dd5f3db37a470216f6", + "versionName": "2.5.5", + "versionCode": 18, "buildFlavor": "release" } diff --git a/public/downloads/boss-android-v2.5.5-release.aab b/public/downloads/boss-android-v2.5.5-release.aab new file mode 100644 index 0000000..fa5ea2b Binary files /dev/null and b/public/downloads/boss-android-v2.5.5-release.aab differ diff --git a/public/downloads/boss-android-v2.5.5-release.apk b/public/downloads/boss-android-v2.5.5-release.apk new file mode 100644 index 0000000..8816e80 Binary files /dev/null and b/public/downloads/boss-android-v2.5.5-release.apk differ diff --git a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts new file mode 100644 index 0000000..d3dd99f --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { rejectDispatchPlan } from "@/lib/boss-data"; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ projectId: string; planId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId, planId } = await context.params; + + try { + const result = await rejectDispatchPlan({ + groupProjectId: projectId, + planId, + rejectedBy: session.account, + }); + return NextResponse.json({ + ok: true, + plan: result.plan, + notice: result.notice, + }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/v1/projects/[projectId]/participants/route.ts b/src/app/api/v1/projects/[projectId]/participants/route.ts index cc17eee..c32834a 100644 --- a/src/app/api/v1/projects/[projectId]/participants/route.ts +++ b/src/app/api/v1/projects/[projectId]/participants/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; -import { readState } from "@/lib/boss-data"; +import { isDispatchableThreadProject, readState, replaceGroupChatMembers } from "@/lib/boss-data"; type ConversationParticipant = { projectId: string; @@ -10,6 +10,9 @@ type ConversationParticipant = { folderName: string; avatar: string; isSourceProject: boolean; + status: "active" | "missing_project" | "invalid_target"; + statusLabel?: string; + canOpenProject: boolean; }; function getFallbackAvatar(label: string) { @@ -26,6 +29,8 @@ function buildParticipant( folderName: string, avatar?: string, isSourceProject = false, + status: ConversationParticipant["status"] = "active", + canOpenProject = true, ): ConversationParticipant { return { projectId, @@ -35,6 +40,81 @@ function buildParticipant( folderName, avatar: avatar?.trim() || getFallbackAvatar(threadDisplayName), isSourceProject, + status, + statusLabel: + status === "missing_project" + ? "引用已失效" + : status === "invalid_target" + ? "不是可下发线程" + : undefined, + canOpenProject, + }; +} + +function buildParticipantsPayload( + state: Awaited>, + projectId: string, +) { + const project = state.projects.find((item) => item.id === projectId); + if (!project) { + return null; + } + + const participants = project.isGroup + ? project.groupMembers.map((member) => { + const candidateProject = state.projects.find((item) => item.id === member.projectId); + const device = state.devices.find((item) => item.id === member.deviceId); + const status: ConversationParticipant["status"] = !candidateProject + ? "missing_project" + : isDispatchableThreadProject(candidateProject) + ? "active" + : "invalid_target"; + return buildParticipant( + member.projectId, + member.deviceId, + member.threadId, + member.threadDisplayName, + member.folderName, + device?.avatar, + member.projectId === project.id, + status, + Boolean(candidateProject), + ); + }) + : [ + buildParticipant( + project.id, + project.deviceIds[0] ?? project.id, + project.threadMeta.threadId, + project.threadMeta.threadDisplayName, + project.threadMeta.folderName, + state.devices.find((item) => item.id === project.deviceIds[0])?.avatar, + true, + ), + ]; + + const validParticipantCount = participants.filter((item) => item.status === "active").length; + const invalidParticipantCount = participants.length - validParticipantCount; + const repairRequired = + project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2); + const repairReason = !repairRequired + ? undefined + : validParticipantCount === 0 + ? "当前群聊里还没有可下发的真实线程,请重新添加线程。" + : invalidParticipantCount > 0 + ? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。" + : "当前群聊至少需要 2 个真实线程成员。"; + + return { + ok: true, + projectId: project.id, + isGroup: project.isGroup, + threadMeta: project.threadMeta, + participants, + repairRequired, + repairReason, + validParticipantCount, + invalidParticipantCount, }; } @@ -49,54 +129,47 @@ export async function GET( const { projectId } = await context.params; const state = await readState(); - const project = state.projects.find((item) => item.id === projectId); - - if (!project) { + const payload = buildParticipantsPayload(state, projectId); + if (!payload) { return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } - - const participants = project.isGroup - ? (project.groupMembers.length > 0 - ? project.groupMembers.map((member) => { - const device = state.devices.find((item) => item.id === member.deviceId); - return buildParticipant( - member.projectId, - member.deviceId, - member.threadId, - member.threadDisplayName, - member.folderName, - device?.avatar, - member.projectId === project.id, - ); - }) - : [ - buildParticipant( - project.id, - project.deviceIds[0] ?? project.id, - project.threadMeta.threadId, - project.threadMeta.threadDisplayName, - project.threadMeta.folderName, - state.devices.find((item) => item.id === project.deviceIds[0])?.avatar, - true, - ), - ]) - : [ - buildParticipant( - project.id, - project.deviceIds[0] ?? project.id, - project.threadMeta.threadId, - project.threadMeta.threadDisplayName, - project.threadMeta.folderName, - state.devices.find((item) => item.id === project.deviceIds[0])?.avatar, - true, - ), - ]; - - return NextResponse.json({ - ok: true, - projectId: project.id, - isGroup: project.isGroup, - threadMeta: project.threadMeta, - participants, - }); + return NextResponse.json(payload); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const body = (await request.json().catch(() => ({}))) as { + memberProjectIds?: string[]; + }; + const { projectId } = await context.params; + + try { + await replaceGroupChatMembers({ + projectId, + memberProjectIds: Array.isArray(body.memberProjectIds) + ? body.memberProjectIds.filter( + (item): item is string => typeof item === "string" && item.trim().length > 0, + ) + : [], + requestedBy: session.account, + }); + const nextState = await readState(); + const payload = buildParticipantsPayload(nextState, projectId); + if (!payload) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + return NextResponse.json(payload); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: 400 }, + ); + } } diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index fe4256c..feea5da 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -4175,6 +4175,50 @@ export async function confirmDispatchPlan(input: { }); } +export async function rejectDispatchPlan(input: { + groupProjectId: string; + planId: string; + rejectedBy: string; +}) { + const result = await mutateState((state) => { + const groupProjectId = input.groupProjectId.trim(); + if (!groupProjectId) throw new Error("PROJECT_NOT_FOUND"); + const groupProject = state.projects.find((item) => item.id === groupProjectId); + if (!groupProject) throw new Error("PROJECT_NOT_FOUND"); + if (!groupProject.isGroup) throw new Error("PROJECT_NOT_GROUP_CHAT"); + requireDispatchActorSession(state, input.rejectedBy); + + const plan = state.dispatchPlans.find((item) => item.planId === input.planId); + if (!plan) throw new Error("DISPATCH_PLAN_NOT_FOUND"); + if (plan.groupProjectId !== groupProjectId) { + throw new Error("DISPATCH_PLAN_PROJECT_MISMATCH"); + } + if (plan.status === "dispatched") { + throw new Error("DISPATCH_PLAN_ALREADY_DISPATCHED"); + } + if (plan.status !== "rejected") { + plan.status = "rejected"; + } + groupProject.approvalState = "rejected"; + const notice = + pushProjectLedgerMessage(state, groupProjectId, { + sender: "master", + senderLabel: "主 Agent", + body: "已拒绝主 Agent 推荐,本次不会下发到任何线程。", + kind: "system_notice", + }) ?? null; + + return { + plan: { ...plan }, + notice: notice ? { ...notice } : null, + }; + }); + + publishBossEvent("project.messages.updated", { projectId: input.groupProjectId }); + publishBossEvent("conversation.updated", { projectId: input.groupProjectId }); + return result; +} + export async function createDispatchExecutionsFromPlan(input: { planId: string; confirmedBy: string; @@ -6266,17 +6310,13 @@ export async function createIndependentGroupChat(input: { return project; } -function createGroupChatFromProjectIds( +function resolveGroupChatThreadProjects( state: BossState, - input: { - requestedProjectIds: string[]; - createdBy: string; - defaultRiskLevel?: Project["riskLevel"]; - }, + requestedProjectIds: string[], ) { const memberProjects: Project[] = []; const seenProjectIds = new Set(); - for (const projectId of input.requestedProjectIds) { + for (const projectId of requestedProjectIds) { if (!projectId || seenProjectIds.has(projectId)) { continue; } @@ -6291,6 +6331,77 @@ function createGroupChatFromProjectIds( } memberProjects.push(memberProject); } + return memberProjects; +} + +export async function replaceGroupChatMembers(input: { + projectId: string; + memberProjectIds: string[]; + requestedBy: string; +}) { + const result = await mutateState((state) => { + const groupProject = state.projects.find((item) => item.id === input.projectId); + if (!groupProject) { + throw new Error("PROJECT_NOT_FOUND"); + } + if (!groupProject.isGroup) { + throw new Error("PROJECT_NOT_GROUP_CHAT"); + } + + const memberProjects = resolveGroupChatThreadProjects(state, input.memberProjectIds); + if (memberProjects.length < 2) { + throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS"); + } + + const now = nowIso(); + groupProject.groupMembers = memberProjects.map((memberProject) => ({ + projectId: memberProject.id, + deviceId: memberProject.deviceIds[0] ?? memberProject.id, + threadId: memberProject.threadMeta.threadId, + threadDisplayName: memberProject.threadMeta.threadDisplayName, + folderName: memberProject.threadMeta.folderName, + })); + groupProject.deviceIds = dedupeStrings(groupProject.groupMembers.map((member) => member.deviceId)); + groupProject.threadMeta.activityIconCount = Math.max(1, groupProject.groupMembers.length); + groupProject.threadMeta.folderName = "群聊"; + groupProject.threadMeta.updatedAt = now; + groupProject.updatedAt = now; + groupProject.lastMessageAt = now; + groupProject.approvalState = "not_required"; + const memberLabel = memberProjects + .map((project) => project.threadMeta.threadDisplayName || project.name) + .join("、"); + pushProjectLedgerMessage(state, groupProject.id, { + sender: "master", + senderLabel: "主 Agent", + body: `已更新群成员:${memberLabel}`, + kind: "system_notice", + sentAt: now, + }); + + return { + project: { ...groupProject }, + groupMembers: groupProject.groupMembers.map((member) => ({ ...member })), + }; + }); + + publishBossEvent("project.messages.updated", { projectId: input.projectId }); + publishBossEvent("conversation.updated", { + projectId: input.projectId, + note: `group members updated by ${input.requestedBy}`, + }); + return result; +} + +function createGroupChatFromProjectIds( + state: BossState, + input: { + requestedProjectIds: string[]; + createdBy: string; + defaultRiskLevel?: Project["riskLevel"]; + }, +) { + const memberProjects = resolveGroupChatThreadProjects(state, input.requestedProjectIds); if (memberProjects.length < 2) { throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS"); } diff --git a/tests/dispatch-execution-result.test.ts b/tests/dispatch-execution-result.test.ts index 2390b80..43e1027 100644 --- a/tests/dispatch-execution-result.test.ts +++ b/tests/dispatch-execution-result.test.ts @@ -11,6 +11,7 @@ let confirmDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projec let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"]; let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"]; +let isDispatchableThreadProject: (typeof import("../src/lib/boss-data"))["isDispatchableThreadProject"]; let readState: (typeof import("../src/lib/boss-data"))["readState"]; let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; let AUTH_SESSION_COOKIE = ""; @@ -37,6 +38,7 @@ async function setup() { completeMasterTaskRoute = completeModule.POST; createAuthSession = data.createAuthSession; createProjectGroupChat = data.createProjectGroupChat; + isDispatchableThreadProject = data.isDispatchableThreadProject; readState = data.readState; writeState = data.writeState; AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; @@ -68,7 +70,7 @@ async function createAuthedRequest(url: string, method: "POST", body: unknown) { async function ensureTwoSingleThreadProjects() { const state = await readState(); - const singles = state.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); + const singles = state.projects.filter((project) => isDispatchableThreadProject(project)); if (singles.length >= 2) { return singles; } @@ -79,7 +81,7 @@ async function ensureTwoSingleThreadProjects() { ...seed, id: "boss-console-clone", name: "Boss 移动控制台副线程", - deviceIds: ["win-gpu-01"], + deviceIds: [...seed.deviceIds], updatedAt: "2026-03-30T10:00:00+08:00", lastMessageAt: "2026-03-30T10:00:00+08:00", preview: "副线程等待主 Agent 汇总阻塞点。", @@ -114,7 +116,7 @@ async function ensureTwoSingleThreadProjects() { }); const nextState = await readState(); - return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); + return nextState.projects.filter((project) => isDispatchableThreadProject(project)); } async function createConfirmedDispatchExecution() { diff --git a/tests/dispatch-plan-confirmation.test.ts b/tests/dispatch-plan-confirmation.test.ts index d8732f2..21f853d 100644 --- a/tests/dispatch-plan-confirmation.test.ts +++ b/tests/dispatch-plan-confirmation.test.ts @@ -9,8 +9,10 @@ let runtimeRoot = ""; let postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"]; let getDispatchPlansRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route"))["GET"]; let confirmDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route"))["POST"]; +let rejectDispatchPlanRoute: (typeof import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route"))["POST"]; let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"]; +let isDispatchableThreadProject: (typeof import("../src/lib/boss-data"))["isDispatchableThreadProject"]; let readState: (typeof import("../src/lib/boss-data"))["readState"]; let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; let AUTH_SESSION_COOKIE = ""; @@ -24,10 +26,11 @@ async function setup() { process.env.BOSS_RUNTIME_ROOT = runtimeRoot; process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); - const [messageModule, plansModule, confirmModule, data, auth] = await Promise.all([ + const [messageModule, plansModule, confirmModule, rejectModule, data, auth] = await Promise.all([ import("../src/app/api/v1/projects/[projectId]/messages/route.ts"), import("../src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts"), import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts"), + import("../src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/reject/route.ts"), import("../src/lib/boss-data.ts"), import("../src/lib/boss-auth.ts"), ]); @@ -35,8 +38,10 @@ async function setup() { postMessageRoute = messageModule.POST; getDispatchPlansRoute = plansModule.GET; confirmDispatchPlanRoute = confirmModule.POST; + rejectDispatchPlanRoute = rejectModule.POST; createAuthSession = data.createAuthSession; createProjectGroupChat = data.createProjectGroupChat; + isDispatchableThreadProject = data.isDispatchableThreadProject; readState = data.readState; writeState = data.writeState; AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; @@ -68,7 +73,7 @@ async function createAuthedRequest(url: string, method: "GET" | "POST", body?: u async function ensureTwoSingleThreadProjects() { const state = await readState(); - const singles = state.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); + const singles = state.projects.filter((project) => isDispatchableThreadProject(project)); if (singles.length >= 2) { return singles; } @@ -79,7 +84,7 @@ async function ensureTwoSingleThreadProjects() { ...seed, id: "boss-console-clone", name: "Boss 移动控制台副线程", - deviceIds: ["win-gpu-01"], + deviceIds: [...seed.deviceIds], updatedAt: "2026-03-30T10:00:00+08:00", lastMessageAt: "2026-03-30T10:00:00+08:00", preview: "副线程等待主 Agent 汇总阻塞点。", @@ -114,7 +119,7 @@ async function ensureTwoSingleThreadProjects() { }); const nextState = await readState(); - return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); + return nextState.projects.filter((project) => isDispatchableThreadProject(project)); } async function createDispatchPlanForTest() { @@ -238,3 +243,52 @@ test("confirming a dispatch plan marks approval_required groups as approved", as assert.ok(nextGroupProject, "expected group project to remain present"); assert.equal(nextGroupProject?.approvalState, "approved"); }); + +test("rejecting a dispatch plan marks approval_required groups as rejected and writes a system notice", async () => { + const { groupProject, dispatchPlan } = await createDispatchPlanForTest(); + + 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 rejectDispatchPlanRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans/${dispatchPlan.planId}/reject`, + "POST", + {}, + ), + { params: Promise.resolve({ projectId: groupProject.id, planId: dispatchPlan.planId }) }, + ); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + plan: { planId: string; status: string }; + notice: { kind: string; body: string }; + }; + assert.equal(payload.ok, true); + assert.equal(payload.plan.planId, dispatchPlan.planId); + assert.equal(payload.plan.status, "rejected"); + assert.equal(payload.notice.kind, "system_notice"); + + 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, "rejected"); + const notice = nextGroupProject?.messages.find( + (message) => + message.kind === "system_notice" && + message.body.includes("已拒绝主 Agent 推荐"), + ); + assert.ok(notice, "expected rejection notice in group chat"); +}); diff --git a/tests/group-participants-repair.test.ts b/tests/group-participants-repair.test.ts new file mode 100644 index 0000000..68abd22 --- /dev/null +++ b/tests/group-participants-repair.test.ts @@ -0,0 +1,217 @@ +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 getParticipantsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/participants/route"))["GET"]; +let updateParticipantsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/participants/route"))["POST"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let AUTH_SESSION_COOKIE = ""; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-group-repair-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [participantsModule, data, auth] = await Promise.all([ + import("../src/app/api/v1/projects/[projectId]/participants/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + getParticipantsRoute = participantsModule.GET; + updateParticipantsRoute = participantsModule.POST; + createAuthSession = data.createAuthSession; + createProjectGroupChat = data.createProjectGroupChat; + readState = data.readState; + writeState = data.writeState; + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(url, { + method, + headers: { + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + ...(body ? { "content-type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +async function ensureTwoSingleThreadProjects() { + const state = await readState(); + const singles = state.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); + if (singles.length >= 2) { + return singles; + } + + assert.ok(singles[0], "expected seeded single-thread project"); + const seed = singles[0]; + const clone = { + ...seed, + id: "repair-thread-clone", + name: "Repair Thread Clone", + deviceIds: ["mac-studio"], + threadMeta: { + ...seed.threadMeta, + projectId: "repair-thread-clone", + threadId: "repair-thread-clone", + threadDisplayName: "维修回归线程", + folderName: "repair-folder", + codexThreadRef: "repair-thread-clone", + codexFolderRef: "repair-folder", + }, + }; + + await writeState({ + ...state, + projects: [...state.projects, clone], + }); + const nextState = await readState(); + return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); +} + +test("GET /api/v1/projects/[projectId]/participants marks dirty groups as repair-required", async () => { + await setup(); + const singles = await ensureTwoSingleThreadProjects(); + const groupProject = await createProjectGroupChat({ + sourceProjectId: singles[0].id, + memberProjectIds: [singles[1].id], + createdBy: "17600003315", + }); + + const state = await readState(); + await writeState({ + ...state, + projects: state.projects.map((project) => + project.id === groupProject.id + ? { + ...project, + groupMembers: [ + { + projectId: "master-agent", + deviceId: "mac-studio", + threadId: "master-agent-thread", + threadDisplayName: "主 Agent 汇总", + folderName: "主控线程", + }, + ], + } + : project, + ), + }); + + const response = await getParticipantsRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/participants`, + "GET", + ), + { params: Promise.resolve({ projectId: groupProject.id }) }, + ); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + repairRequired: boolean; + validParticipantCount: number; + invalidParticipantCount: number; + participants: Array<{ status: string; canOpenProject: boolean }>; + }; + assert.equal(payload.ok, true); + assert.equal(payload.repairRequired, true); + assert.equal(payload.validParticipantCount, 0); + assert.equal(payload.invalidParticipantCount, 1); + assert.equal(payload.participants[0]?.status, "invalid_target"); + assert.equal(payload.participants[0]?.canOpenProject, true); +}); + +test("POST /api/v1/projects/[projectId]/participants replaces dirty members with real thread participants", async () => { + await setup(); + const singles = await ensureTwoSingleThreadProjects(); + const groupProject = await createProjectGroupChat({ + sourceProjectId: singles[0].id, + memberProjectIds: [singles[1].id], + createdBy: "17600003315", + }); + + const state = await readState(); + await writeState({ + ...state, + projects: state.projects.map((project) => + project.id === groupProject.id + ? { + ...project, + groupMembers: [ + { + projectId: "master-agent", + deviceId: "mac-studio", + threadId: "master-agent-thread", + threadDisplayName: "主 Agent 汇总", + folderName: "主控线程", + }, + ], + } + : project, + ), + }); + + const targetIds = singles.slice(0, 2).map((project) => project.id); + const response = await updateParticipantsRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/participants`, + "POST", + { memberProjectIds: targetIds }, + ), + { params: Promise.resolve({ projectId: groupProject.id }) }, + ); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + repairRequired: boolean; + participants: Array<{ projectId: string; status: string }>; + }; + assert.equal(payload.ok, true); + assert.equal(payload.repairRequired, false); + assert.deepEqual( + payload.participants.map((participant) => participant.projectId).sort(), + [...targetIds].sort(), + ); + assert.ok(payload.participants.every((participant) => participant.status === "active")); + + const nextState = await readState(); + const nextGroup = nextState.projects.find((project) => project.id === groupProject.id); + assert.ok(nextGroup, "expected repaired group to remain present"); + assert.deepEqual( + nextGroup?.groupMembers.map((member) => member.projectId).sort(), + [...targetIds].sort(), + ); + const repairNotice = nextGroup?.messages.find( + (message) => + message.kind === "system_notice" && + message.body.includes("已更新群成员"), + ); + assert.ok(repairNotice, "expected a group repair system notice"); +});