From 354c8b1f0b1033194be04432c6859be0ee126c0b Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 3 Apr 2026 05:29:38 +0800 Subject: [PATCH] feat: let master-agent dispatch real threads --- README.md | 2 +- android/app/build.gradle | 4 +- .../main/java/com/hyzq/boss/MainActivity.java | 2 +- .../current_runtime_and_deploy_status_cn.md | 4 +- .../dispatch-plans/[planId]/confirm/route.ts | 18 +- .../dispatch-plans/[planId]/retry/route.ts | 2 +- .../v1/projects/[projectId]/messages/route.ts | 25 ++- src/lib/boss-data.ts | 28 ++- src/lib/boss-master-agent.ts | 180 +++++++++++++++++- tests/dispatch-plan-confirmation.test.ts | 96 ++++++++++ tests/group-message-dispatch-plan.test.ts | 51 +++++ tests/thread-message-preflight.test.ts | 104 ++++++++++ 12 files changed, 495 insertions(+), 21 deletions(-) create mode 100644 tests/thread-message-preflight.test.ts diff --git a/README.md b/README.md index c573f94..08bc2ce 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,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.10`(`versionCode=23`) +- 当前最新 release 构建版本:`2.5.11`(`versionCode=24`) - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 diff --git a/android/app/build.gradle b/android/app/build.gradle index 71b3b2b..394f14c 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 23 - versionName "2.5.10" + versionCode 24 + versionName "2.5.11" 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/MainActivity.java b/android/app/src/main/java/com/hyzq/boss/MainActivity.java index 567ef82..d8ec940 100644 --- a/android/app/src/main/java/com/hyzq/boss/MainActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/MainActivity.java @@ -251,7 +251,7 @@ public class MainActivity extends AppCompatActivity { boolean settingsOk = false; try { - conversations = apiClient.getConversationHome(); + conversations = apiClient.getConversations(); conversationsOk = conversations.ok(); } catch (Exception ignored) { conversationsOk = false; diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index 76a86ce..a115603 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -165,7 +165,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.10`(`versionCode=23`) +- 当前最新 release 构建版本:`2.5.11`(`versionCode=24`) - 当前 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` @@ -185,7 +185,7 @@ cd /Users/kris/code/boss - `2.5.4` 已把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从绿色 `soft panel` 降成轻量列表说明,和会话/设备页统一成同一套微信式产品语言 - `2.5.5` 已补上群资料页“修复群成员”主链:历史脏群现在会明确提示失效成员,并允许重新选择真实线程成员写回群资料 - `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程 -- `2.5.10` 对应这一轮的执行底座收口:`ClawBackendAdapter` 仍默认关闭,但可显式选择并在不可用时自动回退;`OmxTeamBackendAdapter` 已不只是设置项,`dispatch_execution` 在显式选择 `omx-team` 且本机配置可用时会真实走 `OMX Team Runtime` JSON 协议执行 +- `2.5.11` 对应这一轮的主链收口:Android 会话首页改为直接读取 `/api/v1/conversations`,会把这台 Mac 上已导入的 Codex 线程对话直接平铺出来;`master-agent` 对“操作真实线程”的请求会先生成推荐下发方案,确认后再把任务派到真实线程执行;线程无绑定或设备离线时,确认接口会给清晰失败原因,避免假成功状态 - 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net` - `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理 - 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列 diff --git a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts index 72d6682..16e38b3 100644 --- a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts @@ -2,6 +2,17 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { confirmDispatchPlanAndCreateExecutions } from "@/lib/boss-data"; +function confirmFailureMessage(error?: string) { + switch (error) { + case "DISPATCH_TARGET_DEVICE_OFFLINE": + return "目标线程所在设备当前不在线,请先让设备上线后再确认下发。"; + case "DISPATCH_TARGET_THREAD_BINDING_REQUIRED": + return "目标线程还没有绑定真实 Codex 线程,请先修复群成员或重新导入线程后再试。"; + default: + return error ?? "UNKNOWN_ERROR"; + } +} + export async function POST( request: NextRequest, context: { params: Promise<{ projectId: string; planId: string }> }, @@ -36,8 +47,13 @@ export async function POST( collaborationGate: result.collaborationGate, }); } catch (error) { + const reason = error instanceof Error ? error.message : "UNKNOWN_ERROR"; return NextResponse.json( - { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { + ok: false, + code: reason, + message: confirmFailureMessage(reason), + }, { status: 400 }, ); } diff --git a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts index 3bb6fc0..3a751bb 100644 --- a/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/retry/route.ts @@ -50,7 +50,7 @@ export async function POST( if (!project) { return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } - if (!project.isGroup) { + if (!project.isGroup && project.id !== "master-agent") { return NextResponse.json({ ok: false, message: "PROJECT_NOT_GROUP_CHAT" }, { status: 400 }); } diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 3cd714d..351ab02 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -5,6 +5,7 @@ import { queueGroupDispatchPlan, queueThreadConversationReplyTask, replyToMasterAgentUserMessage, + shouldRecommendMasterAgentDispatchPlan, } from "@/lib/boss-master-agent"; import { evaluatePermissionPolicy } from "@/lib/execution/permission-policy"; @@ -19,6 +20,17 @@ function dispatchFailureNotice(error?: string) { } } +function threadConversationFailureMessage(error?: string) { + switch (error) { + case "THREAD_BINDING_REQUIRED": + return "当前线程还没有绑定真实 Codex 线程,请先重新导入该线程后再试。"; + case "THREAD_TARGET_DEVICE_OFFLINE": + return "当前线程所在设备不在线,请先让对应设备上线后再试。"; + default: + return error ?? "UNKNOWN_ERROR"; + } +} + export async function POST( request: NextRequest, context: { params: Promise<{ projectId: string }> }, @@ -37,8 +49,10 @@ export async function POST( const state = await readState(); const project = state.projects.find((item) => item.id === projectId); const shouldCreateDispatchPlan = - project?.isGroup && - project.id !== "master-agent" && + Boolean(project) && + ((project?.isGroup && project.id !== "master-agent") || + (project?.id === "master-agent" && + shouldRecommendMasterAgentDispatchPlan(state, (body.body ?? "").trim()))) && (body.kind ?? "text") === "text" && (body.body ?? "").trim().length > 0; @@ -195,8 +209,13 @@ export async function POST( collaborationGate, }); } catch (error) { + const reason = error instanceof Error ? error.message : "UNKNOWN_ERROR"; return NextResponse.json( - { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { + ok: false, + code: reason, + message: threadConversationFailureMessage(reason), + }, { status: 400 }, ); } diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 4567805..2bc2dda 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -5188,7 +5188,7 @@ function upsertDispatchPlanInState( 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"); + if (!canOwnDispatchPlans(groupProject)) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_INVALID"); const validatedTargets = normalizeDispatchPlanTargetsForCreate(state, input.targets); const existing = state.dispatchPlans.find( @@ -5255,6 +5255,10 @@ export async function listDispatchPlansByProject(groupProjectId: string) { .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } +function canOwnDispatchPlans(project: Project) { + return project.isGroup || project.id === "master-agent"; +} + function applyDispatchPlanConfirmationInState( state: BossState, input: { @@ -5315,7 +5319,7 @@ export async function rejectDispatchPlan(input: { 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"); + if (!canOwnDispatchPlans(groupProject)) throw new Error("PROJECT_NOT_GROUP_CHAT"); requireDispatchActorSession(state, input.rejectedBy); const plan = state.dispatchPlans.find((item) => item.planId === input.planId); @@ -5533,6 +5537,23 @@ function ensureDispatchExecutionTasksInState( return executions.map((execution) => ensureDispatchExecutionTaskInState(state, plan, execution)); } +function validateDispatchExecutionTarget( + state: BossState, + target: DispatchPlanTarget, +) { + const project = state.projects.find((item) => item.id === target.projectId); + if (!project || project.isGroup) { + throw new Error("DISPATCH_TARGET_PROJECT_NOT_FOUND"); + } + if (!project.threadMeta.codexThreadRef?.trim()) { + throw new Error("DISPATCH_TARGET_THREAD_BINDING_REQUIRED"); + } + const device = state.devices.find((item) => item.id === target.deviceId); + if (!device || device.status !== "online") { + throw new Error("DISPATCH_TARGET_DEVICE_OFFLINE"); + } +} + export async function confirmDispatchPlanAndCreateExecutions(input: { groupProjectId: string; planId: string; @@ -5544,7 +5565,7 @@ export async function confirmDispatchPlanAndCreateExecutions(input: { 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"); + if (!canOwnDispatchPlans(groupProject)) throw new Error("PROJECT_NOT_GROUP_CHAT"); const plan = applyDispatchPlanConfirmationInState(state, { planId: input.planId, @@ -5582,6 +5603,7 @@ export async function confirmDispatchPlanAndCreateExecutions(input: { if (targets.length === 0) { throw new Error("DISPATCH_EXECUTION_TARGETS_REQUIRED"); } + targets.forEach((target) => validateDispatchExecutionTarget(state, target)); const createdAt = nowIso(); executions = targets.map((target) => { const execution: DispatchExecution = { diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 52db2f8..c6346b5 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -35,6 +35,7 @@ import { getClawBackendSelectionState, } from "@/lib/execution/backends/claw-backend"; import { getOmxTeamBackendSelectionState } from "@/lib/execution/backends/omx-team-backend"; +import type { OrchestrationBackendId } from "@/lib/execution/orchestration-backend"; import { listExecutionBackendChoices, selectExecutionBackend } from "@/lib/execution/backend-selector"; import { selectOrchestrationBackend } from "@/lib/execution/orchestration-backend-selector"; import { resolveRuntimeRelevantMemories } from "@/lib/execution/memory-resolver"; @@ -1133,6 +1134,73 @@ function summarizeDispatchRequest(requestText: string) { return `${compact.slice(0, 33)}...`; } +const MASTER_AGENT_DISPATCH_KEYWORDS = [ + "线程", + "项目", + "文件夹", + "codex", + "操作", + "处理", + "执行", + "修复", + "同步", + "部署", + "查看", + "检查", + "分析", + "回复", + "下发", + "让", + "继续", +]; + +function normalizeDispatchLookupText(value: string) { + return value.trim().toLowerCase(); +} + +function scoreMasterAgentDispatchCandidate(project: Project, requestText: string) { + const request = normalizeDispatchLookupText(requestText); + if (!request) { + return 0; + } + + let score = 0; + const fields = [ + project.name, + project.threadMeta.threadDisplayName, + project.threadMeta.folderName, + project.threadMeta.codexFolderRef?.split("/").filter(Boolean).pop(), + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value && value.length >= 2)); + + for (const field of fields) { + if (request.includes(field.toLowerCase())) { + score += field === project.threadMeta.threadDisplayName ? 8 : field === project.threadMeta.folderName ? 6 : 4; + } + } + + return score; +} + +export function shouldRecommendMasterAgentDispatchPlan( + state: Awaited>, + requestText: string, +) { + const request = normalizeDispatchLookupText(requestText); + if (!request) { + return false; + } + + if (MASTER_AGENT_DISPATCH_KEYWORDS.some((keyword) => request.includes(keyword))) { + return true; + } + + return state.projects + .filter((project) => isDispatchableThreadProject(project)) + .some((project) => scoreMasterAgentDispatchCandidate(project, requestText) > 0); +} + function collectGroupDispatchTargets( state: Awaited>, project: Project, @@ -1174,11 +1242,53 @@ function collectGroupDispatchTargets( }); } +function collectMasterAgentDispatchTargets( + state: Awaited>, + requestText: string, +): DispatchPlanTarget[] { + const onlineDeviceIds = new Set( + state.devices.filter((device) => device.status === "online").map((device) => device.id), + ); + const candidates = state.projects + .filter((project) => isDispatchableThreadProject(project)) + .filter((project) => project.deviceIds.some((deviceId) => onlineDeviceIds.has(deviceId))) + .map((project) => ({ + project, + score: scoreMasterAgentDispatchCandidate(project, requestText), + })) + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return right.project.updatedAt.localeCompare(left.project.updatedAt); + }); + + const picked = candidates.some((candidate) => candidate.score > 0) + ? candidates.filter((candidate) => candidate.score > 0).slice(0, 5) + : candidates.slice(0, 3); + + return picked.map(({ project }) => ({ + deviceId: project.deviceIds[0] ?? project.id, + projectId: project.id, + threadId: project.threadMeta.threadId, + threadDisplayName: project.threadMeta.threadDisplayName, + folderName: project.threadMeta.folderName, + codexFolderRef: project.threadMeta.codexFolderRef, + codexThreadRef: project.threadMeta.codexThreadRef, + reason: `主 Agent 会话“${summarizeDispatchRequest(requestText)}”需要该线程补充状态或执行建议。`, + })); +} + function summarizeGroupDispatchPlan(requestText: string, targets: DispatchPlanTarget[]) { const targetLabels = targets.map((target) => target.threadDisplayName).filter(Boolean); return `主 Agent 建议先按线程分发这条群聊消息:${summarizeDispatchRequest(requestText)}${targetLabels.length > 0 ? `。建议目标:${targetLabels.join("、")}` : ""}`; } +function summarizeMasterAgentDispatchPlan(requestText: string, targets: DispatchPlanTarget[]) { + const targetLabels = targets.map((target) => target.threadDisplayName).filter(Boolean); + return `主 Agent 建议先把这条请求分发给以下线程:${summarizeDispatchRequest(requestText)}${targetLabels.length > 0 ? `。建议目标:${targetLabels.join("、")}` : ""}`; +} + function buildGroupDispatchPlanPrompt(project: Project, requestText: string) { const memberDigest = (project.groupMembers.length > 0 ? project.groupMembers @@ -1208,6 +1318,29 @@ function buildGroupDispatchPlanPrompt(project: Project, requestText: string) { ].join("\n"); } +function buildMasterAgentDispatchPlanPrompt( + state: Awaited>, + requestText: string, +) { + const candidateDigest = state.projects + .filter((project) => isDispatchableThreadProject(project)) + .slice(0, 12) + .map( + (project) => + `${project.id} / ${project.threadMeta.threadDisplayName} / ${project.threadMeta.folderName} / device=${project.deviceIds[0] ?? "unknown"}`, + ) + .join("\n"); + + return [ + "你正在处理 Boss 控制台的主 Agent 线程调度建议任务。", + "目标不是直接回复用户,而是为这条主 Agent 消息推荐下一步应分发到哪些真实线程。", + `projectId: master-agent`, + `requestText: ${requestText}`, + "dispatchableThreads:", + candidateDigest || "无", + ].join("\n"); +} + type GroupDispatchRecommendationResult = | { ok: true; @@ -1244,6 +1377,20 @@ async function resolveGroupOrchestrationBackend(project: Project) { }; } +function resolveNativeMasterAgentOrchestrationBackend(): { + requestedBackendId: undefined; + orchestrationBackendId: OrchestrationBackendId; + orchestrationBackendLabel: string; + orchestrationFallbackReason: undefined; +} { + return { + requestedBackendId: undefined, + orchestrationBackendId: "boss-native-orchestrator", + orchestrationBackendLabel: "Boss Native Orchestrator", + orchestrationFallbackReason: undefined, + }; +} + async function resolveGroupDispatchPlanTask(taskId: string): Promise { const task = await getMasterAgentTask(taskId); if (!task) { @@ -1259,22 +1406,29 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise item.id === deviceId); + if (!device || device.status !== "online") { + throw new Error("THREAD_TARGET_DEVICE_OFFLINE"); + } return queueMasterAgentTask({ projectId: project.id, taskType: "conversation_reply", diff --git a/tests/dispatch-plan-confirmation.test.ts b/tests/dispatch-plan-confirmation.test.ts index 6f29d00..3837723 100644 --- a/tests/dispatch-plan-confirmation.test.ts +++ b/tests/dispatch-plan-confirmation.test.ts @@ -166,6 +166,34 @@ async function createDispatchPlanForTest() { return { groupProject, dispatchPlan: payload.dispatchPlan }; } +async function createMasterAgentDispatchPlanForTest() { + await setup(); + const memberProjects = await ensureTwoSingleThreadProjects(); + assert.ok(memberProjects.length >= 2, "expected dispatchable single-thread projects"); + + const response = await postMessageRoute( + await createAuthedRequest( + "http://127.0.0.1:3000/api/v1/projects/master-agent/messages", + "POST", + { body: "请操作真实线程,先让南区试产线回归只回复:主Agent确认链路正常" }, + ), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(response.status, 200); + const payload = (await response.json()) as { + dispatchPlan: + | { + planId: string; + targets: Array<{ projectId: string }>; + orchestrationBackendId?: string; + orchestrationBackendLabel?: string; + } + | null; + }; + assert.ok(payload.dispatchPlan, "expected master-agent dispatch plan"); + return { dispatchPlan: payload.dispatchPlan }; +} + test("GET /api/v1/projects/[projectId]/dispatch-plans lists the latest group dispatch plans", async () => { const { groupProject, dispatchPlan } = await createDispatchPlanForTest(); @@ -271,6 +299,74 @@ test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms assert.equal(executionTask?.orchestrationBackendLabel, "Boss Native Orchestrator"); }); +test("master-agent dispatch plans can also be confirmed and create queued executions", async () => { + const { dispatchPlan } = await createMasterAgentDispatchPlanForTest(); + const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId; + assert.ok(approvedTargetProjectId, "expected a recommended target project"); + + const response = await confirmDispatchPlanRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/master-agent/dispatch-plans/${dispatchPlan.planId}/confirm`, + "POST", + { approvedTargetProjectIds: [approvedTargetProjectId] }, + ), + { params: Promise.resolve({ projectId: "master-agent", planId: dispatchPlan.planId }) }, + ); + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + plan: { status: string; confirmedTargetProjectIds: string[] }; + executions: Array<{ targetProjectId: string; status: string }>; + notice: { body: string } | null; + }; + assert.equal(payload.ok, true); + assert.equal(payload.plan.status, "dispatched"); + assert.deepEqual(payload.plan.confirmedTargetProjectIds, [approvedTargetProjectId]); + assert.equal(payload.executions.length, 1); + assert.equal(payload.executions[0]?.targetProjectId, approvedTargetProjectId); + assert.equal(payload.executions[0]?.status, "queued"); + assert.match(payload.notice?.body ?? "", /已确认下发到 1 个线程/); +}); + +test("confirm rejects targets whose device is offline before creating queued executions", async () => { + const { groupProject, dispatchPlan } = await createDispatchPlanForTest(); + const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId; + const targetDeviceId = dispatchPlan.targets[0]?.deviceId; + assert.ok(approvedTargetProjectId, "expected a recommended target project"); + assert.ok(targetDeviceId, "expected a target device"); + + const state = await readState(); + await writeState({ + ...state, + devices: state.devices.map((device) => + device.id === targetDeviceId + ? { + ...device, + status: "offline" as const, + } + : device, + ), + }); + + 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, 400); + const payload = (await response.json()) as { ok: boolean; code: string; message: string }; + assert.equal(payload.ok, false); + assert.equal(payload.code, "DISPATCH_TARGET_DEVICE_OFFLINE"); + assert.equal(payload.message, "目标线程所在设备当前不在线,请先让设备上线后再确认下发。"); + + const nextState = await readState(); + const createdExecution = nextState.dispatchExecutions.find((item) => item.planId === dispatchPlan.planId); + assert.equal(createdExecution, undefined); +}); + test("confirming a dispatch plan marks approval_required groups as approved", async () => { const { groupProject, dispatchPlan } = await createDispatchPlanForTest(); const approvedTargetProjectId = dispatchPlan.targets[0]?.projectId; diff --git a/tests/group-message-dispatch-plan.test.ts b/tests/group-message-dispatch-plan.test.ts index 1f5b4c6..ef7fbaa 100644 --- a/tests/group-message-dispatch-plan.test.ts +++ b/tests/group-message-dispatch-plan.test.ts @@ -226,6 +226,57 @@ test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for sin assert.equal(payload.collaborationGate.isGroup, false); }); +test("POST /api/v1/projects/master-agent/messages returns a dispatch plan for thread-operation requests", async () => { + await setup(); + const memberProjects = await ensureTwoSingleThreadProjects(); + assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects"); + + const response = await POST( + await createAuthedRequest("master-agent", { + body: "请操作真实线程,先让北区试产线回归只回复:主Agent线程操作正常", + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + message: { id: string; body: string }; + dispatchPlan: null | { + groupProjectId: string; + requestMessageId: string; + status: string; + targets: Array<{ projectId: string }>; + summary: string; + }; + masterReply?: { ok: boolean; taskId?: string }; + collaborationGate: { isGroup: boolean }; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.message.body, "请操作真实线程,先让北区试产线回归只回复:主Agent线程操作正常"); + assert.ok(payload.dispatchPlan, "expected dispatch plan in master-agent response"); + assert.equal(payload.dispatchPlan?.groupProjectId, "master-agent"); + assert.equal(payload.dispatchPlan?.requestMessageId, payload.message.id); + assert.equal(payload.dispatchPlan?.status, "pending_user_confirmation"); + assert.ok( + (payload.dispatchPlan?.targets ?? []).some((target) => target.projectId === "dispatch-thread-a"), + "expected dispatch plan to include the matching real thread target", + ); + assert.match(payload.dispatchPlan?.summary ?? "", /北区试产线回归|主 Agent/); + assert.equal(payload.collaborationGate.isGroup, false); + assert.equal(payload.masterReply?.ok, true); + + const nextState = await readState(); + const queuedDispatchTask = nextState.masterAgentTasks.find( + (task) => + task.projectId === "master-agent" && + task.requestMessageId === payload.message.id && + task.taskType === "group_dispatch_plan", + ); + assert.ok(queuedDispatchTask, "expected master-agent thread-op request to enqueue a dispatch recommendation task"); +}); + test("POST /api/v1/projects/[projectId]/messages marks approval_required groups as pending user approval", async () => { await setup(); const memberProjects = await ensureTwoSingleThreadProjects(); diff --git a/tests/thread-message-preflight.test.ts b/tests/thread-message-preflight.test.ts new file mode 100644 index 0000000..4faa052 --- /dev/null +++ b/tests/thread-message-preflight.test.ts @@ -0,0 +1,104 @@ +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 postMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let AUTH_SESSION_COOKIE = ""; +let baseState: Awaited>; + +async function setup() { + if (runtimeRoot) return; + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-thread-preflight-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [messageModule, data, auth] = await Promise.all([ + import("../src/app/api/v1/projects/[projectId]/messages/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + postMessageRoute = messageModule.POST; + createAuthSession = data.createAuthSession; + readState = data.readState; + writeState = data.writeState; + baseState = structuredClone(await readState()); + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test.beforeEach(async () => { + await setup(); + await writeState(structuredClone(baseState)); +}); + +async function createAuthedRequest(projectId: string, body: { body: string }) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify(body), + }); +} + +test("single-thread message rejects projects without a real codex thread binding", async () => { + await setup(); + const state = await readState(); + const singleProject = state.projects.find( + (project) => project.id !== "master-agent" && !project.isGroup, + ); + assert.ok(singleProject, "expected a seeded single-thread project"); + + await writeState({ + ...state, + projects: state.projects.map((project) => + project.id === singleProject.id + ? { + ...project, + threadMeta: { + ...project.threadMeta, + codexThreadRef: undefined, + }, + } + : project, + ), + }); + + const response = await postMessageRoute( + await createAuthedRequest(singleProject.id, { body: "请继续处理这个线程" }), + { params: Promise.resolve({ projectId: singleProject.id }) }, + ); + + assert.equal(response.status, 400); + const payload = (await response.json()) as { ok: boolean; code: string; message: string }; + assert.equal(payload.ok, false); + assert.equal(payload.code, "THREAD_BINDING_REQUIRED"); + assert.equal(payload.message, "当前线程还没有绑定真实 Codex 线程,请先重新导入该线程后再试。"); + + const nextState = await readState(); + const queuedTask = nextState.masterAgentTasks.find( + (task) => task.projectId === singleProject.id && task.taskType === "conversation_reply", + ); + assert.equal(queuedTask, undefined); +});