From 3b2bf59b65fa07f6ef1d06f34c1cc40a249230fb Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 30 Mar 2026 10:41:52 +0800 Subject: [PATCH] feat: add dispatch plan confirmation flow --- .../dispatch-plans/[planId]/confirm/route.ts | 43 +++ .../[projectId]/dispatch-plans/route.ts | 26 ++ .../v1/projects/[projectId]/messages/route.ts | 44 ++- src/lib/boss-data.ts | 269 +++++++++++++----- src/lib/boss-master-agent.ts | 194 ++++++++++--- tests/dispatch-plan-confirmation.test.ts | 205 +++++++++++++ tests/group-message-dispatch-plan.test.ts | 86 ++++++ 7 files changed, 755 insertions(+), 112 deletions(-) create mode 100644 src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts create mode 100644 src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts create mode 100644 tests/dispatch-plan-confirmation.test.ts 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 new file mode 100644 index 0000000..27af590 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { confirmDispatchPlanAndCreateExecutions } 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 body = (await request.json().catch(() => ({}))) as { + approvedTargetProjectIds?: string[]; + }; + const { projectId, planId } = await context.params; + + try { + const result = await confirmDispatchPlanAndCreateExecutions({ + groupProjectId: projectId, + planId, + confirmedBy: session.account, + approvedTargetProjectIds: Array.isArray(body.approvedTargetProjectIds) + ? body.approvedTargetProjectIds.filter( + (item): item is string => typeof item === "string" && item.trim().length > 0, + ) + : [], + }); + + return NextResponse.json({ + ok: true, + plan: result.plan, + executions: result.executions, + 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]/dispatch-plans/route.ts b/src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts new file mode 100644 index 0000000..45de39c --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { listDispatchPlansByProject, readState } from "@/lib/boss-data"; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + const state = await readState(); + const project = state.projects.find((item) => item.id === projectId); + if (!project) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + if (!project.isGroup) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_GROUP_CHAT" }, { status: 400 }); + } + + const plans = await listDispatchPlansByProject(projectId); + return NextResponse.json({ ok: true, plans }); +} diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 24b9ca1..ee265c2 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -28,6 +28,14 @@ export async function POST( kind: body.kind ?? "text", }); let dispatchPlan = null; + let dispatchRecommendation: + | { + ok: boolean; + taskId?: string; + status: "completed" | "failed" | "skipped"; + error?: string; + } + | null = null; let masterReply: | { ok: boolean; reason?: string; message?: string; accountId?: string; requestId?: string } | undefined; @@ -41,12 +49,27 @@ export async function POST( message.body.trim().length > 0; if (shouldCreateDispatchPlan) { - dispatchPlan = await queueGroupDispatchPlan({ - groupProjectId: projectId, - requestMessageId: message.id, - requestText: message.body, - requestedBy: session.account, - }); + try { + const recommendation = await queueGroupDispatchPlan({ + groupProjectId: projectId, + requestMessageId: message.id, + requestText: message.body, + requestedBy: session.account, + }); + dispatchRecommendation = recommendation; + dispatchPlan = recommendation.dispatchPlan; + } catch (error) { + dispatchRecommendation = { + ok: false, + status: "failed", + error: error instanceof Error ? error.message : "GROUP_DISPATCH_PLAN_FAILED", + }; + } + } else { + dispatchRecommendation = { + ok: false, + status: "skipped", + }; } if (projectId === "master-agent" && (body.kind ?? "text") === "text" && message.body.trim()) { @@ -76,7 +99,14 @@ export async function POST( approvalState: "not_required" as const, }; - return NextResponse.json({ ok: true, message, masterReply, dispatchPlan, collaborationGate }); + return NextResponse.json({ + ok: true, + message, + masterReply, + dispatchPlan, + dispatchRecommendation, + collaborationGate, + }); } catch (error) { return NextResponse.json( { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index fc9ae07..f675279 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -15,6 +15,7 @@ export type MessageSender = "master" | "device" | "user" | "ops" | "audit"; // single-message forwarding, bundle forwarding, and the legacy notice shape. export type MessageKind = | "text" + | "system_notice" | "voice_intent" | "image_intent" | "video_intent" @@ -3814,47 +3815,58 @@ export async function createDispatchPlan(input: { targets: DispatchPlanTarget[]; }) { return mutateState((state) => { - const groupProjectId = input.groupProjectId.trim(); - const requestMessageId = input.requestMessageId.trim(); - const requestedBy = input.requestedBy.trim(); - const summary = input.summary?.trim() ?? ""; - - 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 validatedTargets = normalizeDispatchPlanTargetsForCreate(state, input.targets); - const existing = state.dispatchPlans.find( - (plan) => - plan.groupProjectId === groupProjectId && - plan.requestMessageId === requestMessageId, - ); - if (existing) { - const payloadMatches = - existing.requestedBy === requestedBy && - existing.summary === summary && - sameDispatchPlanTargets(existing.targets, validatedTargets); - if (!payloadMatches) { - throw new Error("DISPATCH_PLAN_RETRY_MISMATCH"); - } - return existing; - } - - const plan: DispatchPlan = { - planId: randomToken("dispatch-plan"), - groupProjectId, - requestMessageId, - requestedBy, - status: "pending_user_confirmation", - targets: validatedTargets, - summary, - createdAt: nowIso(), - }; - state.dispatchPlans.unshift(plan); - return plan; + return upsertDispatchPlanInState(state, input); }); } +function upsertDispatchPlanInState( + state: BossState, + input: { + groupProjectId: string; + requestMessageId: string; + requestedBy: string; + summary?: string; + targets: DispatchPlanTarget[]; + }, +) { + const groupProjectId = input.groupProjectId.trim(); + const requestMessageId = input.requestMessageId.trim(); + const requestedBy = input.requestedBy.trim(); + const summary = input.summary?.trim() ?? ""; + + 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 validatedTargets = normalizeDispatchPlanTargetsForCreate(state, input.targets); + const existing = state.dispatchPlans.find( + (plan) => plan.groupProjectId === groupProjectId && plan.requestMessageId === requestMessageId, + ); + if (existing) { + const payloadMatches = + existing.requestedBy === requestedBy && + existing.summary === summary && + sameDispatchPlanTargets(existing.targets, validatedTargets); + if (!payloadMatches) { + throw new Error("DISPATCH_PLAN_RETRY_MISMATCH"); + } + return existing; + } + + const plan: DispatchPlan = { + planId: randomToken("dispatch-plan"), + groupProjectId, + requestMessageId, + requestedBy, + status: "pending_user_confirmation", + targets: validatedTargets, + summary, + createdAt: nowIso(), + }; + state.dispatchPlans.unshift(plan); + return plan; +} + export async function listDispatchPlansByProject(groupProjectId: string) { const state = await readState(); const normalizedGroupProjectId = groupProjectId.trim(); @@ -3863,42 +3875,53 @@ export async function listDispatchPlansByProject(groupProjectId: string) { .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } +function applyDispatchPlanConfirmationInState( + state: BossState, + input: { + planId: string; + confirmedBy: string; + approvedTargetProjectIds: string[]; + }, +) { + const plan = state.dispatchPlans.find((item) => item.planId === input.planId); + if (!plan) throw new Error("DISPATCH_PLAN_NOT_FOUND"); + if (plan.status === "rejected") throw new Error("DISPATCH_PLAN_REJECTED"); + const confirmedBy = input.confirmedBy.trim(); + if (!confirmedBy) throw new Error("DISPATCH_PLAN_CONFIRMED_BY_REQUIRED"); + requireDispatchActorSession(state, confirmedBy); + const approvedTargetProjectIds = normalizeStringSet(input.approvedTargetProjectIds); + if (approvedTargetProjectIds.length === 0) { + throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_REQUIRED"); + } + const canonicalTargetProjectIds = normalizeStringSet(plan.targets.map((target) => target.projectId)); + if (approvedTargetProjectIds.some((projectId) => !canonicalTargetProjectIds.includes(projectId))) { + throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_INVALID"); + } + if (plan.confirmedBy && plan.confirmedBy !== confirmedBy) { + throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH"); + } + if (plan.confirmedTargetProjectIds?.length && !sameStringSet(plan.confirmedTargetProjectIds, approvedTargetProjectIds)) { + throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_MISMATCH"); + } + + if (plan.status !== "dispatched") { + plan.status = "approved"; + } + if (!plan.confirmedAt) { + plan.confirmedAt = nowIso(); + } + plan.confirmedBy = confirmedBy; + plan.confirmedTargetProjectIds = approvedTargetProjectIds; + return plan; +} + export async function confirmDispatchPlan(input: { planId: string; confirmedBy: string; approvedTargetProjectIds: string[]; }) { return mutateState((state) => { - const plan = state.dispatchPlans.find((item) => item.planId === input.planId); - if (!plan) throw new Error("DISPATCH_PLAN_NOT_FOUND"); - if (plan.status === "rejected") throw new Error("DISPATCH_PLAN_REJECTED"); - const confirmedBy = input.confirmedBy.trim(); - if (!confirmedBy) throw new Error("DISPATCH_PLAN_CONFIRMED_BY_REQUIRED"); - requireDispatchActorSession(state, confirmedBy); - const approvedTargetProjectIds = normalizeStringSet(input.approvedTargetProjectIds); - if (approvedTargetProjectIds.length === 0) { - throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_REQUIRED"); - } - const canonicalTargetProjectIds = normalizeStringSet(plan.targets.map((target) => target.projectId)); - if (approvedTargetProjectIds.some((projectId) => !canonicalTargetProjectIds.includes(projectId))) { - throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_INVALID"); - } - if (plan.confirmedBy && plan.confirmedBy !== confirmedBy) { - throw new Error("DISPATCH_PLAN_CONFIRMED_BY_MISMATCH"); - } - if (plan.confirmedTargetProjectIds?.length && !sameStringSet(plan.confirmedTargetProjectIds, approvedTargetProjectIds)) { - throw new Error("DISPATCH_PLAN_APPROVED_TARGETS_MISMATCH"); - } - - if (plan.status !== "dispatched") { - plan.status = "approved"; - } - if (!plan.confirmedAt) { - plan.confirmedAt = nowIso(); - } - plan.confirmedBy = confirmedBy; - plan.confirmedTargetProjectIds = approvedTargetProjectIds; - return plan; + return applyDispatchPlanConfirmationInState(state, input); }); } @@ -3959,6 +3982,91 @@ export async function createDispatchExecutionsFromPlan(input: { }); } +export async function confirmDispatchPlanAndCreateExecutions(input: { + groupProjectId: string; + planId: string; + confirmedBy: string; + approvedTargetProjectIds: 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"); + + const plan = applyDispatchPlanConfirmationInState(state, { + planId: input.planId, + confirmedBy: input.confirmedBy, + approvedTargetProjectIds: input.approvedTargetProjectIds, + }); + if (plan.groupProjectId !== groupProjectId) { + throw new Error("DISPATCH_PLAN_PROJECT_MISMATCH"); + } + + const canonicalTargetProjectIds = normalizeStringSet(plan.confirmedTargetProjectIds ?? []); + const existingExecutions = state.dispatchExecutions.filter((item) => item.planId === plan.planId); + let executions: DispatchExecution[]; + let createdNotice: Message | null = null; + + if (existingExecutions.length > 0) { + const existingTargetIds = normalizeStringSet(existingExecutions.map((execution) => execution.targetProjectId)); + if (!sameStringSet(existingTargetIds, canonicalTargetProjectIds)) { + throw new Error("DISPATCH_EXECUTION_SET_MISMATCH"); + } + if (plan.status !== "dispatched") { + plan.status = "dispatched"; + } + executions = existingExecutions; + } else { + const targets = plan.targets.filter((target) => + canonicalTargetProjectIds.includes(target.projectId), + ); + if (targets.length === 0) { + throw new Error("DISPATCH_EXECUTION_TARGETS_REQUIRED"); + } + const createdAt = nowIso(); + executions = targets.map((target) => { + const execution: DispatchExecution = { + executionId: randomToken("dispatch-exec"), + planId: plan.planId, + groupProjectId: plan.groupProjectId, + targetProjectId: target.projectId, + targetThreadId: target.threadId, + deviceId: target.deviceId, + status: "queued", + createdAt, + }; + state.dispatchExecutions.unshift(execution); + return execution; + }); + plan.status = "dispatched"; + const targetSummary = executions + .map((execution) => { + const project = state.projects.find((item) => item.id === execution.targetProjectId); + return `《${project?.threadMeta.threadDisplayName ?? project?.name ?? execution.targetProjectId}》`; + }) + .join("、"); + createdNotice = pushProjectLedgerMessage(state, groupProjectId, { + sender: "master", + senderLabel: "主 Agent", + body: `已确认下发到 ${executions.length} 个线程:${targetSummary}。`, + kind: "system_notice", + }); + } + + return { + plan: { ...plan }, + executions: executions.map((execution) => ({ ...execution })), + notice: createdNotice ? { ...createdNotice } : null, + }; + }); + + publishBossEvent("project.messages.updated", { projectId: input.groupProjectId }); + publishBossEvent("conversation.updated", { projectId: input.groupProjectId }); + return result; +} + export async function completeDispatchExecution(payload: { executionId: string; completedByDeviceId: string; @@ -4048,6 +4156,10 @@ export async function completeMasterAgentTask(payload: { replyBody?: string; errorMessage?: string; requestId?: string; + dispatchPlan?: { + summary?: string; + targets: DispatchPlanTarget[]; + }; }) { const result = await mutateState((state) => { const task = state.masterAgentTasks.find((item) => item.taskId === payload.taskId); @@ -4084,6 +4196,7 @@ export async function completeMasterAgentTask(payload: { } let attachmentProjectId: string | undefined; + let createdDispatchPlan: DispatchPlan | undefined; if (task.taskType === "attachment_analysis" && task.attachmentId) { const project = state.projects.find((item) => item.id === task.projectId); const match = project ? findProjectAttachment(project, task.attachmentId) : null; @@ -4124,7 +4237,20 @@ export async function completeMasterAgentTask(payload: { } } - if (!attachmentProjectId && payload.status === "completed" && task.replyBody) { + if (task.taskType === "group_dispatch_plan") { + if (payload.status === "completed") { + if (!payload.dispatchPlan) { + throw new Error("MASTER_AGENT_GROUP_DISPATCH_PLAN_REQUIRED"); + } + createdDispatchPlan = upsertDispatchPlanInState(state, { + groupProjectId: task.projectId, + requestMessageId: task.requestMessageId, + requestedBy: task.requestedByAccount, + summary: payload.dispatchPlan.summary, + targets: payload.dispatchPlan.targets, + }); + } + } else if (!attachmentProjectId && payload.status === "completed" && task.replyBody) { pushProjectLedgerMessage(state, task.projectId, { sender: "master", senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent", @@ -4140,7 +4266,10 @@ export async function completeMasterAgentTask(payload: { }); } - return { ...task }; + return { + ...task, + dispatchPlan: createdDispatchPlan ? { ...createdDispatchPlan } : undefined, + }; }); publishBossEvent("master_agent.task.updated", { diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 08bb4a8..1f4c5bd 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -3,7 +3,7 @@ import { AUTH_SESSION_TTL_MS, aiProviderLabel, appendProjectMessage, - createDispatchPlan, + completeMasterAgentTask, getProjectAttachment, getAttachmentStorageConfig, getRuntimeAiAccountById, @@ -14,6 +14,7 @@ import { updateAttachmentAnalysisResult, updateAiAccountHealth, } from "@/lib/boss-data"; +import type { DispatchPlanTarget, GroupConversationMember, Project } from "@/lib/boss-data"; import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments"; import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss"; import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file"; @@ -225,12 +226,159 @@ function summarizeDispatchRequest(requestText: string) { return `${compact.slice(0, 33)}...`; } +function collectGroupDispatchTargets( + project: Project, + requestText: string, +): DispatchPlanTarget[] { + const members: Array< + Pick< + GroupConversationMember, + "deviceId" | "projectId" | "threadId" | "threadDisplayName" | "folderName" + > + > = + project.groupMembers.length > 0 + ? project.groupMembers + : project.deviceIds.map((deviceId) => ({ + projectId: project.id, + deviceId, + threadId: project.threadMeta.threadId, + threadDisplayName: project.threadMeta.threadDisplayName, + folderName: project.threadMeta.folderName, + })); + + return members + .map((member) => ({ + deviceId: member.deviceId, + projectId: member.projectId, + threadId: member.threadId, + threadDisplayName: member.threadDisplayName, + folderName: member.folderName, + reason: `群聊消息“${summarizeDispatchRequest(requestText)}”需要该线程补充状态或执行建议。`, + })) + .filter((target, index, array) => { + const signature = `${target.projectId}::${target.deviceId}::${target.threadId}`; + return ( + array.findIndex((item) => `${item.projectId}::${item.deviceId}::${item.threadId}` === signature) === + index + ); + }); +} + +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 buildGroupDispatchPlanPrompt(project: Project, requestText: string) { + const memberDigest = (project.groupMembers.length > 0 + ? project.groupMembers + : project.deviceIds.map((deviceId) => ({ + projectId: project.id, + deviceId, + threadId: project.threadMeta.threadId, + threadDisplayName: project.threadMeta.threadDisplayName, + folderName: project.threadMeta.folderName, + })) + ) + .map( + (member) => + `${member.projectId} / ${member.threadDisplayName} / ${member.folderName} / device=${member.deviceId}`, + ) + .join("\n"); + + return [ + "你正在处理 Boss 控制台的群聊分发建议任务。", + "目标不是直接回复用户,而是为这条群聊消息推荐后续需要分发到哪些线程。", + "当前服务端会优先使用已有群成员和线程映射做 recommendation workflow。", + `groupProjectId: ${project.id}`, + `groupProjectName: ${project.name}`, + `requestText: ${requestText}`, + "groupMembers:", + memberDigest || "无", + ].join("\n"); +} + +type GroupDispatchRecommendationResult = + | { + ok: true; + taskId: string; + status: "completed"; + dispatchPlan: NonNullable< + Awaited> + >["dispatchPlan"] | null; + } + | { + ok: false; + taskId: string; + status: "failed"; + dispatchPlan: null; + error: string; + }; + +async function resolveGroupDispatchPlanTask(taskId: string): Promise { + const task = await getMasterAgentTask(taskId); + if (!task) { + throw new Error("MASTER_AGENT_TASK_NOT_FOUND"); + } + if (task.taskType !== "group_dispatch_plan") { + throw new Error("MASTER_AGENT_TASK_TYPE_INVALID"); + } + + try { + const state = await readState(); + const project = state.projects.find((item) => item.id === task.projectId); + if (!project) { + throw new Error("PROJECT_NOT_FOUND"); + } + if (!project.isGroup) { + throw new Error("PROJECT_NOT_GROUP_CHAT"); + } + + const targets = collectGroupDispatchTargets(project, task.requestText); + if (targets.length === 0) { + throw new Error("GROUP_DISPATCH_TARGETS_REQUIRED"); + } + + const completedTask = await completeMasterAgentTask({ + taskId: task.taskId, + deviceId: task.deviceId, + status: "completed", + dispatchPlan: { + summary: summarizeGroupDispatchPlan(task.requestText, targets), + targets, + }, + }); + + return { + ok: true as const, + taskId: task.taskId, + status: "completed" as const, + dispatchPlan: completedTask.dispatchPlan ?? null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "GROUP_DISPATCH_PLAN_FAILED"; + await completeMasterAgentTask({ + taskId: task.taskId, + deviceId: task.deviceId, + status: "failed", + errorMessage: message, + }); + return { + ok: false as const, + taskId: task.taskId, + status: "failed" as const, + dispatchPlan: null, + error: message, + }; + } +} + export async function queueGroupDispatchPlan(params: { groupProjectId: string; requestMessageId: string; requestText: string; requestedBy: string; -}) { +}): Promise { const state = await readState(); const project = state.projects.find((item) => item.id === params.groupProjectId); if (!project) { @@ -240,42 +388,18 @@ export async function queueGroupDispatchPlan(params: { throw new Error("PROJECT_NOT_GROUP_CHAT"); } - const memberTargets = (project.groupMembers.length > 0 - ? project.groupMembers - : project.deviceIds.map((deviceId) => ({ - projectId: project.id, - deviceId, - threadId: project.threadMeta.threadId, - threadDisplayName: project.threadMeta.threadDisplayName, - folderName: project.threadMeta.folderName, - }))) - .map((member) => ({ - deviceId: member.deviceId, - projectId: member.projectId, - threadId: member.threadId, - threadDisplayName: member.threadDisplayName, - folderName: member.folderName, - reason: `群聊消息“${summarizeDispatchRequest(params.requestText)}”需要该线程补充状态或执行建议。`, - })) - .filter((target, index, array) => { - const signature = `${target.projectId}::${target.deviceId}::${target.threadId}`; - return array.findIndex((item) => `${item.projectId}::${item.deviceId}::${item.threadId}` === signature) === index; - }); - - if (memberTargets.length === 0) { - throw new Error("GROUP_DISPATCH_TARGETS_REQUIRED"); - } - - const targetLabels = memberTargets.map((target) => target.threadDisplayName).filter(Boolean); - const summary = `主 Agent 建议先按线程分发这条群聊消息:${summarizeDispatchRequest(params.requestText)}${targetLabels.length > 0 ? `。建议目标:${targetLabels.join("、")}` : ""}`; - - return createDispatchPlan({ - groupProjectId: project.id, + const task = await queueMasterAgentTask({ + projectId: project.id, + taskType: "group_dispatch_plan", requestMessageId: params.requestMessageId, + requestText: params.requestText, + executionPrompt: buildGroupDispatchPlanPrompt(project, params.requestText), requestedBy: params.requestedBy, - summary, - targets: memberTargets, + requestedByAccount: params.requestedBy, + deviceId: state.user.boundDeviceId || "mac-studio", }); + + return resolveGroupDispatchPlanTask(task.taskId); } async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_000) { diff --git a/tests/dispatch-plan-confirmation.test.ts b/tests/dispatch-plan-confirmation.test.ts new file mode 100644 index 0000000..27e6ec1 --- /dev/null +++ b/tests/dispatch-plan-confirmation.test.ts @@ -0,0 +1,205 @@ +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 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 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-task4-")); + 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([ + 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/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + postMessageRoute = messageModule.POST; + getDispatchPlansRoute = plansModule.GET; + confirmDispatchPlanRoute = confirmModule.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: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + 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 at least one seeded single-thread project"); + const seed = singles[0]; + const clonedProject = { + ...seed, + id: "boss-console-clone", + name: "Boss 移动控制台副线程", + deviceIds: ["win-gpu-01"], + updatedAt: "2026-03-30T10:00:00+08:00", + lastMessageAt: "2026-03-30T10:00:00+08:00", + preview: "副线程等待主 Agent 汇总阻塞点。", + threadMeta: { + ...seed.threadMeta, + projectId: "boss-console-clone", + threadId: "thread-boss-ui-clone", + threadDisplayName: "南区试产线回归", + folderName: "阻塞梳理", + updatedAt: "2026-03-30T10:00:00+08:00", + codexThreadRef: "thread-boss-ui-clone", + codexFolderRef: "boss-console-clone", + }, + groupMembers: [], + messages: [ + { + id: "msg-boss-console-clone", + sender: "device" as const, + senderLabel: "Win GPU / Codex", + body: "这里还在等待视觉链路复核。", + sentAt: "2026-03-30T10:00:00+08:00", + kind: "text" as const, + }, + ], + goals: [], + versions: [], + }; + + await writeState({ + ...state, + projects: [...state.projects, clonedProject], + }); + + const nextState = await readState(); + return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); +} + +async function createDispatchPlanForTest() { + await setup(); + const memberProjects = await ensureTwoSingleThreadProjects(); + const groupProject = await createProjectGroupChat({ + sourceProjectId: memberProjects[0].id, + memberProjectIds: [memberProjects[1].id], + createdBy: "17600003315", + }); + + const response = await postMessageRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/messages`, + "POST", + { body: "请主 Agent 推荐要先同步的线程" }, + ), + { params: Promise.resolve({ projectId: groupProject.id }) }, + ); + assert.equal(response.status, 200); + const payload = (await response.json()) as { + dispatchPlan: { planId: string; targets: Array<{ projectId: string }> } | null; + }; + assert.ok(payload.dispatchPlan, "expected seeded dispatch plan"); + return { groupProject, dispatchPlan: payload.dispatchPlan }; +} + +test("GET /api/v1/projects/[projectId]/dispatch-plans lists the latest group dispatch plans", async () => { + const { groupProject, dispatchPlan } = await createDispatchPlanForTest(); + + const response = await getDispatchPlansRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/projects/${groupProject.id}/dispatch-plans`, + "GET", + ), + { params: Promise.resolve({ projectId: groupProject.id }) }, + ); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + plans: Array<{ planId: string; requestMessageId: string; status: string }>; + }; + assert.equal(payload.ok, true); + assert.equal(payload.plans[0]?.planId, dispatchPlan.planId); + assert.equal(payload.plans[0]?.status, "pending_user_confirmation"); +}); + +test("POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm confirms targets, creates executions, and writes a master-agent notice", async () => { + const { groupProject, dispatchPlan } = await createDispatchPlanForTest(); + 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/${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 payload = (await response.json()) as { + ok: boolean; + plan: { planId: string; status: string; confirmedTargetProjectIds: string[] }; + executions: Array<{ planId: string; targetProjectId: string; status: string }>; + }; + assert.equal(payload.ok, true); + assert.equal(payload.plan.planId, dispatchPlan.planId); + assert.equal(payload.plan.status, "dispatched"); + assert.deepEqual(payload.plan.confirmedTargetProjectIds, [approvedTargetProjectId]); + assert.equal(payload.executions.length, 1); + assert.equal(payload.executions[0]?.planId, dispatchPlan.planId); + assert.equal(payload.executions[0]?.targetProjectId, approvedTargetProjectId); + assert.equal(payload.executions[0]?.status, "queued"); + + const nextState = await readState(); + const notice = nextState.projects + .find((project) => project.id === groupProject.id) + ?.messages.find( + (message) => + message.sender === "master" && + message.kind === "system_notice" && + message.body.includes("已确认下发到 1 个线程"), + ); + assert.ok(notice, "expected a master-agent notice in the group chat after confirmation"); +}); diff --git a/tests/group-message-dispatch-plan.test.ts b/tests/group-message-dispatch-plan.test.ts index bbb8d95..80aa25b 100644 --- a/tests/group-message-dispatch-plan.test.ts +++ b/tests/group-message-dispatch-plan.test.ts @@ -149,6 +149,19 @@ test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for gro assert.equal(payload.dispatchPlan?.targets.length, groupProject.groupMembers.length); assert.match(payload.dispatchPlan?.summary ?? "", /阻塞点/); assert.equal(payload.collaborationGate.isGroup, true); + + const nextState = await readState(); + const queuedGroupDispatchTasks = nextState.masterAgentTasks.filter( + (task) => + task.projectId === groupProject.id && + task.requestMessageId === payload.message.id && + task.taskType === "group_dispatch_plan", + ); + assert.equal( + queuedGroupDispatchTasks.length, + 1, + "expected group messages to enqueue a master-agent dispatch recommendation task", + ); }); test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for single-thread projects", async () => { @@ -176,3 +189,76 @@ test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for sin assert.equal(payload.dispatchPlan, null); assert.equal(payload.collaborationGate.isGroup, false); }); + +test("POST /api/v1/projects/[projectId]/messages keeps message success when group dispatch recommendation fails", 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(); + const brokenMember = state.projects + .find((project) => project.id === groupProject.id) + ?.groupMembers[0]; + assert.ok(brokenMember, "expected group chat to have a member we can corrupt"); + await writeState({ + ...state, + projects: state.projects.map((project) => + project.id === groupProject.id + ? { + ...project, + groupMembers: [ + { + ...brokenMember, + projectId: "missing-project-for-dispatch", + }, + ], + } + : 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; + message: { id: string; body: string }; + dispatchPlan: null; + dispatchRecommendation: { + ok: boolean; + taskId?: string; + status: string; + error?: string; + }; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.message.body, "请重新梳理下待确认项"); + assert.equal(payload.dispatchPlan, null); + assert.equal(payload.dispatchRecommendation.ok, false); + assert.equal(payload.dispatchRecommendation.status, "failed"); + assert.match(payload.dispatchRecommendation.error ?? "", /DISPATCH_TARGET_PROJECT_NOT_FOUND/); + + const nextState = await readState(); + const savedMessage = nextState.projects + .find((project) => project.id === groupProject.id) + ?.messages.find((message) => message.id === payload.message.id); + assert.ok(savedMessage, "expected user message to remain persisted even when dispatch recommendation fails"); + + const failedTask = nextState.masterAgentTasks.find( + (task) => + task.projectId === groupProject.id && + task.requestMessageId === payload.message.id && + task.taskType === "group_dispatch_plan", + ); + assert.ok(failedTask, "expected failed dispatch recommendation task to be recorded"); + assert.equal(failedTask?.status, "failed"); +});