feat: let master-agent dispatch real threads

This commit is contained in:
kris
2026-04-03 05:29:38 +08:00
parent ad7dd94d95
commit 354c8b1f0b
12 changed files with 495 additions and 21 deletions

View File

@@ -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 / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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 队列

View File

@@ -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 },
);
}

View File

@@ -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 });
}

View File

@@ -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 },
);
}

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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;

View File

@@ -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();

View 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);
});