From 038c2bd088054596e798a3f31960f0242ee87a53 Mon Sep 17 00:00:00 2001 From: kris Date: Mon, 30 Mar 2026 12:03:43 +0800 Subject: [PATCH] fix: harden dispatch and device import flows --- README.md | 4 + docs/architecture/ai_handoff_index_cn.md | 4 + .../api_and_service_inventory_cn.md | 13 +- .../current_runtime_and_deploy_status_cn.md | 5 +- .../[deviceId]/import-draft/apply/route.ts | 16 +- .../[deviceId]/import-draft/review/route.ts | 22 +- .../devices/[deviceId]/import-draft/route.ts | 14 +- .../[deviceId]/import-draft/select/route.ts | 16 +- src/lib/boss-data.ts | 342 ++++++++++++++---- src/lib/boss-device-auth.ts | 42 +++ src/lib/boss-master-agent.ts | 172 +++++++++ tests/device-import-draft.test.ts | 282 ++++++++++++++- tests/dispatch-execution-result.test.ts | 48 +++ 13 files changed, 872 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index c56bfb1..b4c8b22 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,9 @@ Android APK: - 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页 - 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总 - 当前设备导入主链已补上第一轮后端闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户可提交勾选结果、触发主 Agent 风格的导入决议,并把选中的线程真正落成聊天窗口 +- 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总 +- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `import draft`,不再绕过勾选/应用阶段直接把旧项目目录导入为聊天窗口 +- 当前设备导入 `review` 已补 owner/admin 鉴权,并会留下 `device_import_resolution` master task 轨迹,再把决议写回草稿和会话账本 - 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页 - 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView - `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退 @@ -176,6 +179,7 @@ device-agent 当前职责: - 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务 - 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete` - 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本 +- 设备导入审核当前也会落 `device_import_resolution` 任务轨迹,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解 - 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat` 当前常驻默认值: diff --git a/docs/architecture/ai_handoff_index_cn.md b/docs/architecture/ai_handoff_index_cn.md index c50b4cc..ace4cc8 100644 --- a/docs/architecture/ai_handoff_index_cn.md +++ b/docs/architecture/ai_handoff_index_cn.md @@ -90,6 +90,7 @@ - `POST /api/v1/projects/[projectId]/dispatch-plans/[planId]/confirm` 正常,已支持把推荐目标确认成真正的线程执行单 - `GET /api/v1/devices/[deviceId]/import-draft` 正常,已支持读取设备导入草稿与最新决议 - `POST /api/v1/devices/[deviceId]/import-draft/select|review|apply` 正常,已支持设备候选线程勾选、导入决议和落地成真实聊天窗口 +- 这些设备导入接口当前仅允许 `highest_admin` 或设备所属账号访问 - `GET /api/v1/attachments/[attachmentId]/download` 正常,已支持会话鉴权下载和 task token 下载 - `POST /api/auth/login` 正常,会写入 `boss_session` - `boss_session` 当前默认保持 30 天 @@ -135,6 +136,9 @@ - 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名 - 当前群聊编排主链已经补到第一阶段:群聊消息先进入主 Agent,主 Agent 生成推荐下发方案,用户确认后再创建执行单;执行完成后线程原始结果会回群,主 Agent 再追加汇总 - 当前设备导入主链已经补到第一阶段:设备 heartbeat 可上报真实候选线程,系统会生成导入草稿;用户勾选后可生成导入决议,并把选中的线程真正落成聊天窗口 +- 当前设备导入草稿不会再被旧 `projects` 字段绕过;只有 `apply` 之后,候选线程才会真正变成聊天窗口 +- 当前设备导入 `review` 已经会留下 `device_import_resolution` master task 轨迹,但决议内容仍是服务端 heuristic 版,尚未真正交给 `local-agent -> codex exec` +- 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果 - 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片 - 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发 - 当前附件与存储配置页位于 `我的 > 附件与存储`:默认使用服务器文件存储,用户可按账号切到阿里 OSS 私有桶;下载链会优先使用附件上传时固化的 OSS 快照,避免用户后续改配置后旧附件失效 diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index be61f4d..4e02615 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -540,6 +540,8 @@ - 当前行为: - 返回最新 `deviceImportDraft` - 如果已经做过导入决议,还会一并返回最新 `deviceImportResolution` +- 当前保护: + - 仅 `highest_admin` 或设备所属账号可读 #### `POST /api/v1/devices/[deviceId]/import-draft/select` @@ -549,14 +551,18 @@ - 当前行为: - 只接受当前导入草稿里真实存在的候选项 - 提交后会把草稿推进到 `pending_resolution` +- 当前保护: + - 仅 `highest_admin` 或设备所属账号可写 #### `POST /api/v1/devices/[deviceId]/import-draft/review` - 用途:生成主 Agent 风格的设备导入决议 - 当前行为: - - 会基于已勾选候选生成 `deviceImportResolution` - - 当前决议为服务端同步 heuristic 版 + - 会先落一条 `device_import_resolution` master task,再把决议写回 `deviceImportResolution` + - 当前决议内容仍为服务端 heuristic 版 - 决议会区分 `create_thread_conversation | attach_existing | skip` +- 当前保护: + - 仅 `highest_admin` 或设备所属账号可写 #### `POST /api/v1/devices/[deviceId]/import-draft/apply` @@ -565,6 +571,9 @@ - `create_thread_conversation` 会生成新的单线程会话 - `attach_existing` 会补充现有会话的设备 / 线程映射 - 应用后草稿和决议都会变成 `applied` + - 重复 apply 同一份 resolution 不会再重复创建线程会话 +- 当前保护: + - 仅 `highest_admin` 或设备所属账号可写 #### `GET /api/v1/devices/[deviceId]/skills` diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index a50aaaa..4e872fc 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -99,6 +99,9 @@ cd /Users/kris/code/boss - 当前单条消息转发会在目标会话中显示为普通转发消息,并保留 `forwardSource`;多条消息会落成 `forward_bundle` 聊天记录卡片,并保留来源会话、时间范围和摘要条目 - 当前群聊编排主链已补上第一轮闭环:群聊文本消息会先进入主 Agent 生成推荐下发方案;用户确认后会创建真正的线程执行单,并写入系统通知;执行完成后会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 +- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口 +- 当前 `dispatch_execution` 完成回写已补幂等,重复完成同一个线程执行单不会再重复向群聊追加线程原始回复和主 Agent 汇总 +- 当前设备导入 `review` 已补 owner/admin 鉴权,并会留下 `device_import_resolution` master task 轨迹;导入草稿在 `apply` 后再次 heartbeat 也不会从 `applied` 回退成 `resolved` - 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新 - 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node` - 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本` @@ -139,7 +142,7 @@ cd /Users/kris/code/boss - 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net` - `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理 - 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列 -- 当前设备导入决议仍是服务端同步 heuristic 版,下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议 +- 当前设备导入决议已经会先落 `device_import_resolution` master task 再写回结果,但决议内容仍是服务端 heuristic 版;下一阶段可再升级成真正通过 `local-agent -> codex exec` 参与理解的主 Agent 决议 ## 2. 服务器状态 diff --git a/src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts b/src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts index aeca1d0..289f6e0 100644 --- a/src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts +++ b/src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts @@ -1,22 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; -import { requireRequestSession } from "@/lib/boss-auth"; +import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth"; import { applyDeviceImportResolution } from "@/lib/boss-data"; export async function POST( request: NextRequest, context: { params: Promise<{ deviceId: string }> }, ) { - const session = await requireRequestSession(request); - if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); - } - const { deviceId } = await context.params; + const auth = await authorizeDeviceSessionRequest(request, deviceId); + if (!auth.ok) { + return NextResponse.json( + { ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" }, + { status: auth.status }, + ); + } try { const result = await applyDeviceImportResolution({ deviceId, - appliedBy: session.account, + appliedBy: auth.session.account, }); return NextResponse.json({ ok: true, ...result }); } catch (error) { diff --git a/src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts b/src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts index df08ed1..6599c38 100644 --- a/src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts +++ b/src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts @@ -1,24 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; -import { requireRequestSession } from "@/lib/boss-auth"; -import { resolveDeviceImportDraft } from "@/lib/boss-data"; +import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth"; +import { queueDeviceImportResolutionTask } from "@/lib/boss-master-agent"; export async function POST( request: NextRequest, context: { params: Promise<{ deviceId: string }> }, ) { - const session = await requireRequestSession(request); - if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + const { deviceId } = await context.params; + const auth = await authorizeDeviceSessionRequest(request, deviceId); + if (!auth.ok) { + return NextResponse.json( + { ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" }, + { status: auth.status }, + ); } - const { deviceId } = await context.params; - try { - const result = await resolveDeviceImportDraft({ + const result = await queueDeviceImportResolutionTask({ deviceId, - reviewedBy: session.account, + reviewedBy: auth.session.account, }); - return NextResponse.json({ ok: true, ...result }); + return NextResponse.json(result); } catch (error) { return NextResponse.json( { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, diff --git a/src/app/api/v1/devices/[deviceId]/import-draft/route.ts b/src/app/api/v1/devices/[deviceId]/import-draft/route.ts index 2d4ec25..bf692cb 100644 --- a/src/app/api/v1/devices/[deviceId]/import-draft/route.ts +++ b/src/app/api/v1/devices/[deviceId]/import-draft/route.ts @@ -1,17 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; -import { requireRequestSession } from "@/lib/boss-auth"; +import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth"; import { getLatestDeviceImportDraft } from "@/lib/boss-data"; export async function GET( request: NextRequest, context: { params: Promise<{ deviceId: string }> }, ) { - const session = await requireRequestSession(request); - if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); - } - const { deviceId } = await context.params; + const auth = await authorizeDeviceSessionRequest(request, deviceId); + if (!auth.ok) { + return NextResponse.json( + { ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" }, + { status: auth.status }, + ); + } const result = await getLatestDeviceImportDraft(deviceId); return NextResponse.json({ ok: true, ...result }); } diff --git a/src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts b/src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts index f44e42d..02bac59 100644 --- a/src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts +++ b/src/app/api/v1/devices/[deviceId]/import-draft/select/route.ts @@ -1,26 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; -import { requireRequestSession } from "@/lib/boss-auth"; +import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth"; import { selectDeviceImportCandidates } from "@/lib/boss-data"; export async function POST( request: NextRequest, context: { params: Promise<{ deviceId: string }> }, ) { - const session = await requireRequestSession(request); - if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + const { deviceId } = await context.params; + const auth = await authorizeDeviceSessionRequest(request, deviceId); + if (!auth.ok) { + return NextResponse.json( + { ok: false, message: auth.status === 404 ? "DEVICE_NOT_FOUND" : "UNAUTHORIZED" }, + { status: auth.status }, + ); } - const body = (await request.json().catch(() => ({}))) as { selectedCandidateIds?: string[]; }; - const { deviceId } = await context.params; try { const draft = await selectDeviceImportCandidates({ deviceId, selectedCandidateIds: body.selectedCandidateIds ?? [], - selectedBy: session.account, + selectedBy: auth.session.account, }); return NextResponse.json({ ok: true, draft }); } catch (error) { diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 83defaa..c390fd1 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -3984,6 +3984,7 @@ export async function queueMasterAgentTask(payload: { attachmentDownloadExpiresAt?: string; attachmentDownloadUrl?: string; attachmentTextExcerpt?: string; + deviceImportDraftId?: string; dispatchExecutionId?: string; targetProjectId?: string; targetThreadId?: string; @@ -4008,6 +4009,7 @@ export async function queueMasterAgentTask(payload: { attachmentDownloadExpiresAt: payload.attachmentDownloadExpiresAt, attachmentDownloadUrl: payload.attachmentDownloadUrl, attachmentTextExcerpt: payload.attachmentTextExcerpt, + deviceImportDraftId: payload.deviceImportDraftId, dispatchExecutionId: payload.dispatchExecutionId, targetProjectId: payload.targetProjectId, targetThreadId: payload.targetThreadId, @@ -4478,12 +4480,39 @@ function appendDispatchExecutionResultInState( throw new Error("DISPATCH_EXECUTION_DEVICE_MISMATCH"); } + const groupProject = state.projects.find((item) => item.id === payload.groupProjectId); + if (!groupProject) { + throw new Error("PROJECT_NOT_FOUND"); + } + const device = state.devices.find((item) => item.id === payload.completedByDeviceId); const threadTitle = payload.targetThreadDisplayName?.trim() || state.projects.find((item) => item.id === payload.targetProjectId)?.threadMeta.threadDisplayName || payload.targetThreadId; + if (execution.status === "completed" || execution.status === "failed") { + if (execution.status !== payload.status) { + throw new Error("DISPATCH_EXECUTION_COMPLETION_MISMATCH"); + } + const existingMirroredResult = execution.resultMessageId + ? findProjectMessage(groupProject, execution.resultMessageId) + : null; + if ( + payload.status === "completed" && + payload.rawThreadReply?.trim() && + existingMirroredResult && + existingMirroredResult.body !== payload.rawThreadReply.trim() + ) { + throw new Error("DISPATCH_EXECUTION_COMPLETION_MISMATCH"); + } + return { + execution: { ...execution }, + mirroredResult: existingMirroredResult, + masterSummary: null, + }; + } + let mirroredResult: Message | null = null; let masterSummary: Message | null = null; @@ -4706,6 +4735,24 @@ export async function completeMasterAgentTask(payload: { targets: payload.dispatchPlan.targets, }); } + } else if (task.taskType === "device_import_resolution") { + if (!task.deviceImportDraftId) { + throw new Error("MASTER_AGENT_DEVICE_IMPORT_DRAFT_REQUIRED"); + } + const draft = state.deviceImportDrafts.find((item) => item.draftId === task.deviceImportDraftId); + if (!draft) { + throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND"); + } + if (payload.status === "completed") { + const resolutionReply = parseDeviceImportResolutionReply(state, draft, task.replyBody ?? ""); + upsertDeviceImportResolutionInState(state, { + deviceId: draft.deviceId, + reviewedBy: task.requestedByAccount, + summary: resolutionReply.summary, + items: resolutionReply.items, + draftId: draft.draftId, + }); + } } else if (task.taskType === "dispatch_execution") { if (!task.dispatchExecutionId || !task.targetProjectId || !task.targetThreadId) { throw new Error("MASTER_AGENT_DISPATCH_EXECUTION_CONTEXT_REQUIRED"); @@ -5220,11 +5267,13 @@ function upsertDeviceImportDraftFromHeartbeat( deviceId: payload.deviceId, enrollmentId: payload.enrollmentId ?? existing?.enrollmentId, status: - selectedCandidateIds.length > 0 - ? existing?.resolutionId - ? "resolved" - : "pending_resolution" - : "pending_selection", + existing?.status === "applied" && existing.resolutionId && selectedCandidateIds.length > 0 + ? "applied" + : selectedCandidateIds.length > 0 + ? existing?.resolutionId + ? "resolved" + : "pending_resolution" + : "pending_selection", candidates: payload.candidates, selectedCandidateIds, createdAt: existing?.createdAt ?? nowIso(), @@ -5272,6 +5321,21 @@ export async function upsertDeviceHeartbeat(payload: { payload.token, ); + const normalizedCandidates = ensureArray(payload.projectCandidates, []).map((candidate) => + normalizeDeviceImportCandidate({ + deviceId: payload.deviceId, + folderName: candidate.folderName, + folderRef: candidate.folderRef, + threadId: candidate.threadId, + threadDisplayName: candidate.threadDisplayName, + codexFolderRef: candidate.codexFolderRef, + codexThreadRef: candidate.codexThreadRef, + lastActiveAt: candidate.lastActiveAt ?? nowIso(), + suggestedImport: candidate.suggestedImport ?? true, + }), + ); + const shouldAutoImportLegacyProjects = normalizedCandidates.length === 0; + let device = state.devices.find((item) => item.id === payload.deviceId); if (!device) { device = { @@ -5307,56 +5371,44 @@ export async function upsertDeviceHeartbeat(payload: { device.token = claimedEnrollment?.token ?? payload.token ?? device.token; } - for (const projectName of payload.projects) { - const existing = state.projects.find((item) => item.name === projectName); - if (!existing) { - state.projects.push( - normalizeProject({ - id: slugify(projectName), - name: projectName, - pinned: false, - deviceIds: [payload.deviceId], - preview: `${payload.name} 已自动上报项目文件夹`, - updatedAt: nowIso(), - lastMessageAt: nowIso(), - isGroup: false, - unreadCount: 0, - riskLevel: "low", - contextBudgetPct: 80, - contextBudgetLabel: "80%", - messages: [ - { - id: randomToken("auto"), - sender: "device", - senderLabel: payload.name, - body: `本机发现新的项目目录:${projectName}`, - sentAt: nowIso(), - kind: "text", - }, - ], - goals: [], - versions: [], - }), - ); - } else if (!existing.deviceIds.includes(payload.deviceId)) { - existing.deviceIds.push(payload.deviceId); - existing.isGroup = existing.deviceIds.length > 1; + if (shouldAutoImportLegacyProjects) { + for (const projectName of payload.projects) { + const existing = state.projects.find((item) => item.name === projectName); + if (!existing) { + state.projects.push( + normalizeProject({ + id: slugify(projectName), + name: projectName, + pinned: false, + deviceIds: [payload.deviceId], + preview: `${payload.name} 已自动上报项目文件夹`, + updatedAt: nowIso(), + lastMessageAt: nowIso(), + isGroup: false, + unreadCount: 0, + riskLevel: "low", + contextBudgetPct: 80, + contextBudgetLabel: "80%", + messages: [ + { + id: randomToken("auto"), + sender: "device", + senderLabel: payload.name, + body: `本机发现新的项目目录:${projectName}`, + sentAt: nowIso(), + kind: "text", + }, + ], + goals: [], + versions: [], + }), + ); + } else if (!existing.deviceIds.includes(payload.deviceId)) { + existing.deviceIds.push(payload.deviceId); + existing.isGroup = existing.deviceIds.length > 1; + } } } - - const normalizedCandidates = ensureArray(payload.projectCandidates, []).map((candidate) => - normalizeDeviceImportCandidate({ - deviceId: payload.deviceId, - folderName: candidate.folderName, - folderRef: candidate.folderRef, - threadId: candidate.threadId, - threadDisplayName: candidate.threadDisplayName, - codexFolderRef: candidate.codexFolderRef, - codexThreadRef: candidate.codexThreadRef, - lastActiveAt: candidate.lastActiveAt ?? nowIso(), - suggestedImport: candidate.suggestedImport ?? true, - }), - ); const draft = upsertDeviceImportDraftFromHeartbeat(state, { deviceId: payload.deviceId, enrollmentId: claimedEnrollment?.enrollmentId, @@ -5443,6 +5495,75 @@ export async function getLatestDeviceImportDraft(deviceId: string) { return { draft, resolution }; } +export async function previewDeviceImportResolution(input: { deviceId: string }) { + const state = await readState(); + const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId); + if (!draft) throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND"); + if (draft.selectedCandidateIds.length === 0) { + throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED"); + } + const device = state.devices.find((item) => item.id === input.deviceId); + if (!device) throw new Error("DEVICE_NOT_FOUND"); + + const selectedCandidates = draft.candidates.filter((candidate) => + draft.selectedCandidateIds.includes(candidate.candidateId), + ); + const items = selectedCandidates.map((candidate) => + resolveDeviceImportAction(state, input.deviceId, candidate), + ); + + return { + draft: { ...draft }, + device: { ...device }, + items, + summary: summarizeDeviceImportResolution(device.name, items), + }; +} + +function upsertDeviceImportResolutionInState( + state: BossState, + input: { + deviceId: string; + reviewedBy: string; + summary: string; + items: DeviceImportResolutionItem[]; + draftId?: string; + }, +) { + const draft = + state.deviceImportDrafts.find( + (item) => item.draftId === input.draftId || item.deviceId === input.deviceId, + ) ?? null; + if (!draft) throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND"); + if (draft.selectedCandidateIds.length === 0) { + throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED"); + } + + const existingResolution = state.deviceImportResolutions.find((item) => item.draftId === draft.draftId); + const resolution = normalizeDeviceImportResolution({ + resolutionId: existingResolution?.resolutionId ?? draft.resolutionId ?? randomToken("import-resolution"), + draftId: draft.draftId, + deviceId: input.deviceId, + status: "ready", + summary: input.summary, + items: input.items, + createdAt: existingResolution?.createdAt ?? nowIso(), + }); + + draft.status = "resolved"; + draft.updatedAt = nowIso(); + draft.reviewedAt = nowIso(); + draft.reviewedBy = input.reviewedBy; + draft.resolutionId = resolution.resolutionId; + + state.deviceImportResolutions = [ + resolution, + ...state.deviceImportResolutions.filter((item) => item.draftId !== draft.draftId), + ]; + + return { draft: { ...draft }, resolution }; +} + export async function selectDeviceImportCandidates(input: { deviceId: string; selectedCandidateIds: string[]; @@ -5492,34 +5613,96 @@ export async function resolveDeviceImportDraft(input: { const items = selectedCandidates.map((candidate) => resolveDeviceImportAction(state, input.deviceId, candidate), ); - const resolution = normalizeDeviceImportResolution({ - resolutionId: randomToken("import-resolution"), - draftId: draft.draftId, + return upsertDeviceImportResolutionInState(state, { deviceId: input.deviceId, - status: "ready", + reviewedBy: input.reviewedBy, summary: summarizeDeviceImportResolution(device.name, items), items, - createdAt: nowIso(), + draftId: draft.draftId, }); - - draft.status = "resolved"; - draft.updatedAt = nowIso(); - draft.reviewedAt = nowIso(); - draft.reviewedBy = input.reviewedBy; - draft.resolutionId = resolution.resolutionId; - - state.deviceImportResolutions = [ - resolution, - ...state.deviceImportResolutions.filter((item) => item.draftId !== draft.draftId), - ]; - - return { draft: { ...draft }, resolution }; }); publishBossEvent("devices.updated", { deviceId: input.deviceId }); publishBossEvent("conversation.updated", { deviceId: input.deviceId }); return result; } +function parseDeviceImportResolutionReply( + state: BossState, + draft: DeviceImportDraft, + replyBody: string, +) { + const trimmed = replyBody.trim(); + if (!trimmed) { + throw new Error("DEVICE_IMPORT_RESOLUTION_REPLY_REQUIRED"); + } + + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + const jsonCandidate = fencedMatch?.[1]?.trim() ?? trimmed; + let parsed: + | { + summary?: string; + items?: Array<{ + candidateId?: string; + action?: DeviceImportResolutionItem["action"]; + targetProjectId?: string; + reason?: string; + }>; + } + | null = null; + + try { + parsed = JSON.parse(jsonCandidate); + } catch { + throw new Error("DEVICE_IMPORT_RESOLUTION_JSON_INVALID"); + } + + const selectedCandidates = draft.candidates.filter((candidate) => + draft.selectedCandidateIds.includes(candidate.candidateId), + ); + const candidateMap = new Map(selectedCandidates.map((candidate) => [candidate.candidateId, candidate])); + const seenCandidateIds = new Set(); + const items: DeviceImportResolutionItem[] = []; + + for (const rawItem of ensureArray(parsed?.items, [])) { + const candidateId = rawItem?.candidateId?.trim(); + if (!candidateId || seenCandidateIds.has(candidateId)) continue; + const candidate = candidateMap.get(candidateId); + if (!candidate) continue; + seenCandidateIds.add(candidateId); + const heuristic = resolveDeviceImportAction(state, draft.deviceId, candidate); + items.push({ + candidateId, + action: + rawItem.action === "attach_existing" || + rawItem.action === "create_thread_conversation" || + rawItem.action === "skip" + ? rawItem.action + : heuristic.action, + threadDisplayName: candidate.threadDisplayName, + folderName: candidate.folderName, + targetProjectId: + typeof rawItem.targetProjectId === "string" && rawItem.targetProjectId.trim() + ? rawItem.targetProjectId.trim() + : heuristic.targetProjectId, + reason: rawItem.reason?.trim() || heuristic.reason, + }); + } + + for (const candidate of selectedCandidates) { + if (!seenCandidateIds.has(candidate.candidateId)) { + items.push(resolveDeviceImportAction(state, draft.deviceId, candidate)); + } + } + + const device = state.devices.find((item) => item.id === draft.deviceId); + return { + summary: + parsed?.summary?.trim() || + summarizeDeviceImportResolution(device?.name ?? draft.deviceId, items), + items, + }; +} + function buildImportedThreadProject(device: Device, candidate: DeviceImportCandidate) { const projectId = candidate.codexThreadRef?.trim() && candidate.codexFolderRef?.trim() @@ -5592,8 +5775,21 @@ export async function applyDeviceImportResolution(input: { ? state.projects.find((project) => project.id === item.targetProjectId) : undefined; if (item.action === "create_thread_conversation" && !targetProject) { - targetProject = buildImportedThreadProject(device, candidate); - state.projects.unshift(targetProject); + const draftProject = buildImportedThreadProject(device, candidate); + targetProject = + state.projects.find((project) => project.id === draftProject.id) ?? + state.projects.find( + (project) => + !project.isGroup && + project.deviceIds.includes(device.id) && + ((candidate.codexThreadRef && + project.threadMeta.codexThreadRef === candidate.codexThreadRef) || + project.threadMeta.threadId === candidate.threadId), + ); + if (!targetProject) { + targetProject = draftProject; + state.projects.unshift(targetProject); + } } else if (item.action === "attach_existing" && !targetProject) { continue; } diff --git a/src/lib/boss-device-auth.ts b/src/lib/boss-device-auth.ts index c6e2b2a..96e3b42 100644 --- a/src/lib/boss-device-auth.ts +++ b/src/lib/boss-device-auth.ts @@ -32,3 +32,45 @@ export async function authorizeDeviceWriteRequest( principal: null, }; } + +export async function authorizeDeviceSessionRequest( + request: NextRequest, + deviceId: string, +) { + const device = await getDevice(deviceId); + const session = await requireRequestSession(request); + + if (!session) { + return { + ok: false as const, + status: 401 as const, + device, + session: null, + }; + } + + if (!device) { + return { + ok: false as const, + status: 404 as const, + device: null, + session, + }; + } + + if (session.role === "highest_admin" || device.account === session.account) { + return { + ok: true as const, + status: 200 as const, + device, + session, + }; + } + + return { + ok: false as const, + status: 403 as const, + device, + session, + }; +} diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 1f4c5bd..80429fe 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -6,9 +6,11 @@ import { completeMasterAgentTask, getProjectAttachment, getAttachmentStorageConfig, + getLatestDeviceImportDraft, getRuntimeAiAccountById, getMasterAgentRuntimeAccount, getMasterAgentTask, + previewDeviceImportResolution, queueMasterAgentTask, readState, updateAttachmentAnalysisResult, @@ -402,6 +404,176 @@ export async function queueGroupDispatchPlan(params: { return resolveGroupDispatchPlanTask(task.taskId); } +function buildDeviceImportResolutionPrompt(params: { + deviceName: string; + deviceId: string; + draftId: string; + selectedCandidates: Array<{ + candidateId: string; + threadDisplayName: string; + folderName: string; + lastActiveAt: string; + }>; + existingProjects: string[]; +}) { + return [ + "你正在处理 Boss 控制台的设备导入决议任务。", + "请根据候选线程和现有会话,给出导入建议。", + "输出必须是 JSON,对象结构如下:", + '{ "summary": "一句中文摘要", "items": [{ "candidateId": "...", "action": "create_thread_conversation|attach_existing|skip", "targetProjectId": "可选", "reason": "中文原因" }] }', + "要求:", + "1. 每个 candidateId 最多出现一次。", + "2. 如果 action=attach_existing,尽量给出 targetProjectId。", + "3. 如果信息不足,也必须给出 reason,不要输出额外解释文本。", + "", + `deviceName: ${params.deviceName}`, + `deviceId: ${params.deviceId}`, + `draftId: ${params.draftId}`, + "selectedCandidates:", + params.selectedCandidates + .map( + (candidate) => + `${candidate.candidateId} / ${candidate.threadDisplayName} / ${candidate.folderName} / ${candidate.lastActiveAt}`, + ) + .join("\n") || "无", + "", + "existingProjects:", + params.existingProjects.join("\n") || "无", + ].join("\n"); +} + +type DeviceImportResolutionTaskResult = + | { + ok: true; + taskId: string; + status: "completed"; + draft: NonNullable>["draft"]>; + resolution: NonNullable>["resolution"]>; + } + | { + ok: false; + taskId: string; + status: "failed"; + draft: Awaited>["draft"]; + resolution: Awaited>["resolution"]; + error: string; + }; + +async function resolveDeviceImportResolutionTask(taskId: string): Promise { + const task = await getMasterAgentTask(taskId); + if (!task) { + throw new Error("MASTER_AGENT_TASK_NOT_FOUND"); + } + if (task.taskType !== "device_import_resolution" || !task.deviceImportDraftId) { + throw new Error("MASTER_AGENT_TASK_TYPE_INVALID"); + } + + const draftRecord = await readState(); + const draft = draftRecord.deviceImportDrafts.find((item) => item.draftId === task.deviceImportDraftId); + if (!draft) { + throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND"); + } + + try { + const proposal = await previewDeviceImportResolution({ deviceId: draft.deviceId }); + await completeMasterAgentTask({ + taskId: task.taskId, + deviceId: task.deviceId, + status: "completed", + replyBody: JSON.stringify( + { + summary: proposal.summary, + items: proposal.items.map((item) => ({ + candidateId: item.candidateId, + action: item.action, + targetProjectId: item.targetProjectId, + reason: item.reason, + })), + }, + null, + 2, + ), + }); + + const latest = await getLatestDeviceImportDraft(draft.deviceId); + return { + ok: true as const, + taskId: task.taskId, + status: "completed" as const, + draft: latest.draft!, + resolution: latest.resolution!, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "DEVICE_IMPORT_RESOLUTION_FAILED"; + await completeMasterAgentTask({ + taskId: task.taskId, + deviceId: task.deviceId, + status: "failed", + errorMessage: message, + }); + const latest = await getLatestDeviceImportDraft(draft.deviceId); + return { + ok: false as const, + taskId: task.taskId, + status: "failed" as const, + draft: latest.draft, + resolution: latest.resolution, + error: message, + }; + } +} + +export async function queueDeviceImportResolutionTask(params: { + deviceId: string; + reviewedBy: string; +}) { + const state = await readState(); + const draft = state.deviceImportDrafts.find((item) => item.deviceId === params.deviceId); + if (!draft) { + throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND"); + } + if (draft.selectedCandidateIds.length === 0) { + throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED"); + } + const device = state.devices.find((item) => item.id === params.deviceId); + if (!device) { + throw new Error("DEVICE_NOT_FOUND"); + } + + const selectedCandidates = draft.candidates.filter((candidate) => + draft.selectedCandidateIds.includes(candidate.candidateId), + ); + const task = await queueMasterAgentTask({ + projectId: "master-agent", + taskType: "device_import_resolution", + requestMessageId: draft.draftId, + requestText: `请审核设备 ${device.name} 的线程导入建议`, + executionPrompt: buildDeviceImportResolutionPrompt({ + deviceName: device.name, + deviceId: device.id, + draftId: draft.draftId, + selectedCandidates: selectedCandidates.map((candidate) => ({ + candidateId: candidate.candidateId, + threadDisplayName: candidate.threadDisplayName, + folderName: candidate.folderName, + lastActiveAt: candidate.lastActiveAt, + })), + existingProjects: state.projects + .filter((project) => !project.isGroup) + .map( + (project) => + `${project.id} / ${project.threadMeta.threadDisplayName} / ${project.threadMeta.folderName} / devices=${project.deviceIds.join(",")}`, + ), + }), + requestedBy: params.reviewedBy, + requestedByAccount: params.reviewedBy, + deviceId: state.user.boundDeviceId || "mac-studio", + deviceImportDraftId: draft.draftId, + }); + + return resolveDeviceImportResolutionTask(task.taskId); +} + async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts index 1dcba3e..36a79ea 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -53,9 +53,19 @@ test.after(async () => { }); async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) { + return createAuthedRequestFor("17600003315", "highest_admin", url, method, body); +} + +async function createAuthedRequestFor( + account: string, + role: "member" | "admin" | "highest_admin", + url: string, + method: "GET" | "POST", + body?: unknown, +) { const session = await createAuthSession({ - account: "17600003315", - role: "highest_admin", + account, + role, displayName: "Boss 超级管理员", loginMethod: "password", }); @@ -177,6 +187,15 @@ test("device import draft flow scans candidates, selects imports, resolves sugge ["create_thread_conversation"], ); + const reviewedState = await readState(); + const resolutionTask = reviewedState.masterAgentTasks.find( + (task) => + task.taskType === "device_import_resolution" && + task.deviceImportDraftId && + task.status === "completed", + ); + assert.ok(resolutionTask, "expected import review to leave a master-agent task trace"); + const applyResponse = await applyImportDraftRoute( await createAuthedRequest( `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/apply`, @@ -207,3 +226,262 @@ test("device import draft flow scans candidates, selects imports, resolves sugge assert.equal(appliedDraft?.status, "applied"); assert.equal(appliedResolution?.status, "applied"); }); + +test("heartbeat candidates no longer auto-create chat windows from legacy projects when import draft is present", async () => { + await setup(); + + const enrollmentResponse = await createEnrollmentRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", { + name: "ThinkPad", + avatar: "T", + account: "17600003315", + endpoint: "pc://thinkpad.local", + note: "legacy projects should not auto import", + }), + ); + assert.equal(enrollmentResponse.status, 200); + const enrollmentPayload = (await enrollmentResponse.json()) as { + enrollment: { pairingCode: string }; + device: { id: string }; + }; + + const beforeState = await readState(); + const beforeCount = beforeState.projects.filter((project) => project.name === "Legacy Folder").length; + + const heartbeatResponse = await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + deviceId: enrollmentPayload.device.id, + pairingCode: enrollmentPayload.enrollment.pairingCode, + name: "ThinkPad", + avatar: "T", + account: "17600003315", + status: "online", + quota5h: 60, + quota7d: 75, + projects: ["Legacy Folder"], + endpoint: "pc://thinkpad.local", + projectCandidates: [ + { + folderName: "Legacy Folder", + folderRef: "legacy-folder", + threadId: "thread-legacy-1", + threadDisplayName: "Legacy 线程", + codexFolderRef: "legacy-folder", + codexThreadRef: "thread-legacy-1", + lastActiveAt: "2026-03-30T10:30:00+08:00", + suggestedImport: true, + }, + ], + }), + }), + ); + assert.equal(heartbeatResponse.status, 200); + + const nextState = await readState(); + const afterCount = nextState.projects.filter((project) => project.name === "Legacy Folder").length; + assert.equal(afterCount, beforeCount, "legacy project folders should wait for import apply"); + + const draft = nextState.deviceImportDrafts.find((item) => item.deviceId === enrollmentPayload.device.id); + assert.ok(draft, "expected import draft to be created"); +}); + +test("device import apply is idempotent and heartbeat preserves applied status", async () => { + await setup(); + + const enrollmentResponse = await createEnrollmentRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", { + name: "Studio Mac", + avatar: "S", + account: "17600003315", + endpoint: "mac://studio.local", + note: "idempotent import apply", + }), + ); + assert.equal(enrollmentResponse.status, 200); + const enrollmentPayload = (await enrollmentResponse.json()) as { + enrollment: { pairingCode: string }; + device: { id: string }; + }; + + const heartbeatPayload = { + deviceId: enrollmentPayload.device.id, + pairingCode: enrollmentPayload.enrollment.pairingCode, + name: "Studio Mac", + avatar: "S", + account: "17600003315", + status: "online" as const, + quota5h: 68, + quota7d: 82, + projects: [], + endpoint: "mac://studio.local", + projectCandidates: [ + { + folderName: "导入目录", + folderRef: "import-folder", + threadId: "thread-import-1", + threadDisplayName: "导入线程一", + codexFolderRef: "import-folder", + codexThreadRef: "thread-import-1", + lastActiveAt: "2026-03-30T10:40:00+08:00", + suggestedImport: true, + }, + ], + }; + + assert.equal( + ( + await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(heartbeatPayload), + }), + ) + ).status, + 200, + ); + + const draftResponse = await getImportDraftRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`, + "GET", + ), + { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, + ); + const draftPayload = (await draftResponse.json()) as { + draft: { candidates: Array<{ candidateId: string }> }; + }; + const selectedCandidateIds = draftPayload.draft.candidates.map((candidate) => candidate.candidateId); + + assert.equal( + ( + await selectImportDraftRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/select`, + "POST", + { selectedCandidateIds }, + ), + { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, + ) + ).status, + 200, + ); + assert.equal( + ( + await reviewImportDraftRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/review`, + "POST", + {}, + ), + { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, + ) + ).status, + 200, + ); + + const applyUrl = `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/apply`; + const firstApply = await applyImportDraftRoute( + await createAuthedRequest(applyUrl, "POST", {}), + { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, + ); + assert.equal(firstApply.status, 200); + + const secondApply = await applyImportDraftRoute( + await createAuthedRequest(applyUrl, "POST", {}), + { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, + ); + assert.equal(secondApply.status, 200); + + let nextState = await readState(); + const importedProjects = nextState.projects.filter( + (project) => project.threadMeta.codexThreadRef === "thread-import-1", + ); + assert.equal(importedProjects.length, 1, "replaying apply should not duplicate imported thread windows"); + + assert.equal( + ( + await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(heartbeatPayload), + }), + ) + ).status, + 200, + ); + + nextState = await readState(); + const appliedDraft = nextState.deviceImportDrafts.find((item) => item.deviceId === enrollmentPayload.device.id); + assert.equal(appliedDraft?.status, "applied", "later heartbeats should not regress applied drafts"); +}); + +test("device import routes reject unrelated logged-in members", async () => { + await setup(); + + const enrollmentResponse = await createEnrollmentRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", { + name: "Build Mac", + avatar: "B", + account: "17600003315", + endpoint: "mac://build.local", + note: "route auth test", + }), + ); + assert.equal(enrollmentResponse.status, 200); + const enrollmentPayload = (await enrollmentResponse.json()) as { + enrollment: { pairingCode: string }; + device: { id: string }; + }; + + assert.equal( + ( + await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + deviceId: enrollmentPayload.device.id, + pairingCode: enrollmentPayload.enrollment.pairingCode, + name: "Build Mac", + avatar: "B", + account: "17600003315", + status: "online", + quota5h: 71, + quota7d: 80, + projects: [], + endpoint: "mac://build.local", + projectCandidates: [ + { + folderName: "受控目录", + folderRef: "secured-folder", + threadId: "thread-secured", + threadDisplayName: "受控线程", + codexFolderRef: "secured-folder", + codexThreadRef: "thread-secured", + lastActiveAt: "2026-03-30T10:50:00+08:00", + suggestedImport: true, + }, + ], + }), + }), + ) + ).status, + 200, + ); + + const outsiderRequest = await createAuthedRequestFor( + "15500001111", + "member", + `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft`, + "GET", + ); + const getResponse = await getImportDraftRoute(outsiderRequest, { + params: Promise.resolve({ deviceId: enrollmentPayload.device.id }), + }); + assert.equal(getResponse.status, 403); +}); diff --git a/tests/dispatch-execution-result.test.ts b/tests/dispatch-execution-result.test.ts index c148ece..2390b80 100644 --- a/tests/dispatch-execution-result.test.ts +++ b/tests/dispatch-execution-result.test.ts @@ -220,3 +220,51 @@ test("POST /api/v1/master-agent/tasks/[taskId]/complete mirrors raw thread repli ); assert.ok(masterSummary, "expected master-agent summary to be appended after the raw thread reply"); }); + +test("POST /api/v1/master-agent/tasks/[taskId]/complete is idempotent for repeated dispatch execution completions", async () => { + const { groupProject, execution, executionTask } = await createConfirmedDispatchExecution(); + const completionBody = { + deviceId: execution.deviceId, + status: "completed" as const, + dispatchExecutionId: execution.executionId, + targetProjectId: execution.targetProjectId, + targetThreadId: execution.targetThreadId, + rawThreadReply: "线程A已经完成阻塞点整理,待你确认最终回滚窗口。", + replyBody: "主 Agent 汇总:线程A已返回阻塞点整理,下一步建议安排回滚窗口确认。", + }; + + const firstResponse = await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${executionTask.taskId}/complete`, + "POST", + completionBody, + ), + { params: Promise.resolve({ taskId: executionTask.taskId }) }, + ); + assert.equal(firstResponse.status, 200); + + const secondResponse = await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${executionTask.taskId}/complete`, + "POST", + completionBody, + ), + { params: Promise.resolve({ taskId: executionTask.taskId }) }, + ); + assert.equal(secondResponse.status, 200); + + const nextState = await readState(); + const groupMessages = nextState.projects.find((project) => project.id === groupProject.id)?.messages ?? []; + const mirroredReplies = groupMessages.filter( + (message) => + message.sender === "device" && + message.body.includes("线程A已经完成阻塞点整理"), + ); + const masterSummaries = groupMessages.filter( + (message) => + message.sender === "master" && + message.body.includes("主 Agent 汇总:线程A已返回阻塞点整理"), + ); + assert.equal(mirroredReplies.length, 1); + assert.equal(masterSummaries.length, 1); +});