feat: add dispatch plan confirmation flow
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
26
src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts
Normal file
26
src/app/api/v1/projects/[projectId]/dispatch-plans/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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<ReturnType<typeof completeMasterAgentTask>>
|
||||
>["dispatchPlan"] | null;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
taskId: string;
|
||||
status: "failed";
|
||||
dispatchPlan: null;
|
||||
error: string;
|
||||
};
|
||||
|
||||
async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispatchRecommendationResult> {
|
||||
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<GroupDispatchRecommendationResult> {
|
||||
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) {
|
||||
|
||||
205
tests/dispatch-plan-confirmation.test.ts
Normal file
205
tests/dispatch-plan-confirmation.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user