feat: let master-agent dispatch real threads
This commit is contained in:
@@ -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 / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 队列
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<ReturnType<typeof readState>>,
|
||||
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<ReturnType<typeof readState>>,
|
||||
project: Project,
|
||||
@@ -1174,11 +1242,53 @@ function collectGroupDispatchTargets(
|
||||
});
|
||||
}
|
||||
|
||||
function collectMasterAgentDispatchTargets(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
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<ReturnType<typeof readState>>,
|
||||
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<GroupDispatchRecommendationResult> {
|
||||
const task = await getMasterAgentTask(taskId);
|
||||
if (!task) {
|
||||
@@ -1259,22 +1406,29 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispat
|
||||
if (!project) {
|
||||
throw new Error("PROJECT_NOT_FOUND");
|
||||
}
|
||||
if (!project.isGroup) {
|
||||
const isMasterAgentProject = project.id === "master-agent";
|
||||
if (!project.isGroup && !isMasterAgentProject) {
|
||||
throw new Error("PROJECT_NOT_GROUP_CHAT");
|
||||
}
|
||||
|
||||
const targets = collectGroupDispatchTargets(state, project, task.requestText);
|
||||
const targets = isMasterAgentProject
|
||||
? collectMasterAgentDispatchTargets(state, task.requestText)
|
||||
: collectGroupDispatchTargets(state, project, task.requestText);
|
||||
if (targets.length === 0) {
|
||||
throw new Error("GROUP_DISPATCH_TARGETS_REQUIRED");
|
||||
}
|
||||
const orchestrationBackend = await resolveGroupOrchestrationBackend(project);
|
||||
const orchestrationBackend = isMasterAgentProject
|
||||
? resolveNativeMasterAgentOrchestrationBackend()
|
||||
: await resolveGroupOrchestrationBackend(project);
|
||||
|
||||
const completedTask = await completeMasterAgentTask({
|
||||
taskId: task.taskId,
|
||||
deviceId: task.deviceId,
|
||||
status: "completed",
|
||||
dispatchPlan: {
|
||||
summary: summarizeGroupDispatchPlan(task.requestText, targets),
|
||||
summary: isMasterAgentProject
|
||||
? summarizeMasterAgentDispatchPlan(task.requestText, targets)
|
||||
: summarizeGroupDispatchPlan(task.requestText, targets),
|
||||
targets,
|
||||
requestedOrchestrationBackendId: orchestrationBackend.requestedBackendId,
|
||||
orchestrationBackendId: orchestrationBackend.orchestrationBackendId,
|
||||
@@ -1318,18 +1472,23 @@ export async function queueGroupDispatchPlan(params: {
|
||||
if (!project) {
|
||||
throw new Error("PROJECT_NOT_FOUND");
|
||||
}
|
||||
if (!project.isGroup) {
|
||||
if (!project.isGroup && project.id !== "master-agent") {
|
||||
throw new Error("PROJECT_NOT_GROUP_CHAT");
|
||||
}
|
||||
|
||||
const orchestrationBackend = await resolveGroupOrchestrationBackend(project);
|
||||
const isMasterAgentProject = project.id === "master-agent";
|
||||
const orchestrationBackend = isMasterAgentProject
|
||||
? resolveNativeMasterAgentOrchestrationBackend()
|
||||
: await resolveGroupOrchestrationBackend(project);
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
taskType: "group_dispatch_plan",
|
||||
requestMessageId: params.requestMessageId,
|
||||
requestText: params.requestText,
|
||||
executionPrompt: buildGroupDispatchPlanPrompt(project, params.requestText),
|
||||
executionPrompt: isMasterAgentProject
|
||||
? buildMasterAgentDispatchPlanPrompt(state, params.requestText)
|
||||
: buildGroupDispatchPlanPrompt(project, params.requestText),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedBy,
|
||||
deviceId: state.user.boundDeviceId || "mac-studio",
|
||||
@@ -1358,8 +1517,15 @@ export async function queueThreadConversationReplyTask(params: {
|
||||
if (project.id === "master-agent") {
|
||||
throw new Error("PROJECT_NOT_THREAD_CONVERSATION");
|
||||
}
|
||||
if (!project.threadMeta.codexThreadRef?.trim()) {
|
||||
throw new Error("THREAD_BINDING_REQUIRED");
|
||||
}
|
||||
|
||||
const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio";
|
||||
const device = state.devices.find((item) => item.id === deviceId);
|
||||
if (!device || device.status !== "online") {
|
||||
throw new Error("THREAD_TARGET_DEVICE_OFFLINE");
|
||||
}
|
||||
return queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
taskType: "conversation_reply",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
104
tests/thread-message-preflight.test.ts
Normal file
104
tests/thread-message-preflight.test.ts
Normal file
@@ -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<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user