diff --git a/README.md b/README.md index 08bc2ce..cd814e0 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Android APK: - 当前设备导入主链已补上真实审核闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户提交勾选后会先排队 `device_import_resolution` 主 Agent 任务,前台进入“主 Agent 审核中”并自动刷新,任务完成后才写回正式导入建议,再把选中的线程真正落成聊天窗口 - 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入` - 当前设备导入前台文案与状态卡已收口:会明确显示 `等待候选线程 / 等待勾选 / 建议已生成 / 已导入`,并在导入后回显真正落到会话首页的线程名 +- 当前已导入设备也支持主动同步项目理解:设备详情页新增 `同步项目理解`,会由主 Agent 主动询问这台设备上活跃线程的项目目标、当前进度和技术架构,并把结果回写到项目理解和项目记忆 - 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `import draft`,不再绕过勾选/应用阶段直接把旧项目目录导入为聊天窗口 - 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本 @@ -314,6 +315,7 @@ npm run aab:release - 原生聊天页当前会即时渲染本地发送中消息,并且只有在用户接近底部或本次发送是主动触发时才自动滚到底 - 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页” - `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新 +- `/api/v1/devices/[deviceId]/project-understanding-sync` 已可用:设备详情页可手动触发当前设备活跃线程的项目理解同步,适用于已经导入过线程的生产设备 - 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句 - 我的页新增 `主 Agent 提示词 / 记忆` 入口,`/me/master-agent` 会展示管理员全局主提示词、用户主提示词、当前对话附加提示词、组合预览,以及当前用户的通用记忆和跨项目项目记忆 - 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node` diff --git a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java index 6ad2e5d..755b24f 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -399,6 +399,10 @@ public class BossApiClient { return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/import-draft/apply", new JSONObject()); } + public ApiResponse syncDeviceProjectUnderstanding(String deviceId) throws IOException, JSONException { + return requestWithRestore("POST", "/api/v1/devices/" + encode(deviceId) + "/project-understanding-sync", new JSONObject()); + } + public ApiResponse getAccounts() throws IOException, JSONException { return requestWithRestore("GET", "/api/v1/accounts", null); } diff --git a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java index 44f99b9..07bf474 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java @@ -75,6 +75,7 @@ public class DeviceDetailActivity extends BossScreenActivity { )); } appendContent(BossUi.buildMenuRow(this, "导入项目", "勾选这台设备上要暴露到会话首页的项目和线程", null, v -> openImportDraft())); + appendContent(BossUi.buildMenuRow(this, "同步项目理解", "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构", null, v -> syncProjectUnderstanding())); appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills())); setRefreshing(false); } @@ -93,6 +94,33 @@ public class DeviceDetailActivity extends BossScreenActivity { startActivity(intent); } + private void syncProjectUnderstanding() { + setRefreshing(true); + executor.execute(() -> { + try { + BossApiClient.ApiResponse response = apiClient.syncDeviceProjectUnderstanding(deviceId); + if (!response.ok()) throw new IllegalStateException(response.message()); + JSONObject payload = response.json; + JSONArray queuedTasks = payload.optJSONArray("queuedTasks"); + int queuedCount = queuedTasks == null ? 0 : queuedTasks.length(); + runOnUiThread(() -> { + setRefreshing(false); + if (queuedCount <= 0) { + showMessage("当前设备没有可同步的活跃线程。"); + } else { + showMessage("主 Agent 已开始同步 " + queuedCount + " 个项目理解。"); + } + reload(); + }); + } catch (Exception error) { + runOnUiThread(() -> { + setRefreshing(false); + showMessage("同步失败:" + error.getMessage()); + }); + } + }); + } + private void openEditDialog() { executor.execute(() -> { try { diff --git a/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java b/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java new file mode 100644 index 0000000..c4c1c19 --- /dev/null +++ b/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java @@ -0,0 +1,178 @@ +package com.hyzq.boss; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.Intent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowToast; +import org.robolectric.util.ReflectionHelpers; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 34) +public class DeviceDetailActivityTest { + @Test + public void renderDeviceShowsSyncProjectUnderstandingEntry() { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + View content = activity.findViewById(R.id.screen_content); + assertTrue(viewTreeContainsText(content, "同步项目理解")); + assertTrue(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构")); + } + + @Test + public void tappingSyncProjectUnderstandingCallsApiAndShowsQueuedCount() { + TestDeviceDetailActivity activity = Robolectric + .buildActivity( + TestDeviceDetailActivity.class, + new Intent() + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1") + .putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio") + ) + .setup() + .get(); + + View syncLabel = findViewWithText(activity.findViewById(R.id.screen_content), "同步项目理解"); + syncLabel.getParent().getParent(); + View clickable = findClickableAncestor(syncLabel); + clickable.performClick(); + org.robolectric.Shadows.shadowOf(activity.getMainLooper()).idle(); + + assertEquals(1, activity.fakeClient.syncCalls); + assertEquals("主 Agent 已开始同步 2 个项目理解。", ShadowToast.getTextOfLatestToast()); + } + + private static boolean viewTreeContainsText(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return true; + } + } + if (!(root instanceof ViewGroup)) { + return false; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + if (viewTreeContainsText(group.getChildAt(index), expectedText)) { + return true; + } + } + return false; + } + + @Nullable + private static View findViewWithText(View root, String expectedText) { + if (root instanceof TextView) { + CharSequence text = ((TextView) root).getText(); + if (text != null && text.toString().contains(expectedText)) { + return root; + } + } + if (!(root instanceof ViewGroup)) { + return null; + } + ViewGroup group = (ViewGroup) root; + for (int index = 0; index < group.getChildCount(); index += 1) { + View match = findViewWithText(group.getChildAt(index), expectedText); + if (match != null) { + return match; + } + } + return null; + } + + private static View findClickableAncestor(View view) { + View current = view; + while (current != null && !current.isClickable()) { + if (!(current.getParent() instanceof View)) { + break; + } + current = (View) current.getParent(); + } + return current == null ? view : current; + } + + public static class TestDeviceDetailActivity extends DeviceDetailActivity { + FakeBossApiClient fakeClient; + + @Override + protected void reload() { + if (fakeClient == null) { + fakeClient = new FakeBossApiClient(this); + } + this.apiClient = fakeClient; + try { + ReflectionHelpers.callInstanceMethod( + this, + "renderDevice", + ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload()) + ); + } catch (Exception error) { + throw new RuntimeException(error); + } + } + + private static JSONObject buildDevicePayload() throws Exception { + return new JSONObject() + .put("workspace", new JSONObject() + .put("selectedDevice", new JSONObject() + .put("id", "device-1") + .put("name", "Mac Studio") + .put("avatar", "M") + .put("account", "17600003315") + .put("status", "online") + .put("quota5h", 75) + .put("quota7d", 88) + .put("projects", new JSONArray().put("Boss")) + .put("endpoint", "mac://studio.local") + .put("note", "测试设备"))); + } + } + + private static class FakeBossApiClient extends BossApiClient { + int syncCalls = 0; + + FakeBossApiClient(DeviceDetailActivity activity) { + super(activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), "https://boss.hyzq.net"); + } + + @Override + public ApiResponse syncDeviceProjectUnderstanding(String deviceId) { + syncCalls += 1; + try { + return new ApiResponse( + 200, + new JSONObject() + .put("ok", true) + .put("queuedTasks", new JSONArray() + .put(new JSONObject().put("projectId", "project-1")) + .put(new JSONObject().put("projectId", "project-2"))) + ); + } catch (Exception error) { + throw new RuntimeException(error); + } + } + } +} diff --git a/docs/architecture/api_and_service_inventory_cn.md b/docs/architecture/api_and_service_inventory_cn.md index 79dcaba..534c4bc 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -763,6 +763,18 @@ - 已绑定的生产设备如果在 heartbeat 中携带真实 `projectCandidates[]`,服务端会自动完成 `select + review + apply` - 新设备仍保持人工勾选导入流程,不会被自动跳过 +#### `POST /api/v1/devices/[deviceId]/project-understanding-sync` + +- 用途:对已经导入过线程的设备,手动触发当前活跃项目理解同步 +- 当前行为: + - 读取该设备上已导入的真实线程会话 + - 选出最近活跃的线程项目 + - 为这些项目排隐藏的 `conversation_reply` 主 Agent 任务 + - 强制刷新项目理解,即使上次同步时间较近也会重新询问 + - 返回本次排队的任务列表和当前活跃项目摘要 +- 当前保护: + - 仅 `highest_admin` 或设备所属账号可写 + #### `GET /api/v1/devices/[deviceId]/skills` - 用途:读取指定设备已经同步上来的 Skill 列表 diff --git a/docs/architecture/current_runtime_and_deploy_status_cn.md b/docs/architecture/current_runtime_and_deploy_status_cn.md index a115603..a5c734d 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -129,6 +129,7 @@ cd /Users/kris/code/boss - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 - 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名 +- 当前已导入设备也支持主动同步项目理解:设备详情页新增 `同步项目理解`,会直接为这台设备上已导入的活跃线程排隐藏的 `conversation_reply` 主 Agent 任务,回写最新的项目目标、当前进度、技术架构和下一步建议 - 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员 - 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口 diff --git a/src/app/api/v1/devices/[deviceId]/project-understanding-sync/route.ts b/src/app/api/v1/devices/[deviceId]/project-understanding-sync/route.ts new file mode 100644 index 0000000..9682322 --- /dev/null +++ b/src/app/api/v1/devices/[deviceId]/project-understanding-sync/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { authorizeDeviceSessionRequest } from "@/lib/boss-device-auth"; +import { syncDeviceProjectUnderstanding } from "@/lib/boss-data"; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ deviceId: string }> }, +) { + 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 syncDeviceProjectUnderstanding({ + deviceId, + requestedByAccount: auth.session.account, + }); + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: 400 }, + ); + } +} diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 1e6224a..87c67fa 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -677,7 +677,7 @@ export interface MasterAgentTask { deviceImportCandidateId?: string; deviceImportCandidateFolderName?: string; projectUnderstandingTargetProjectId?: string; - projectUnderstandingReason?: "heartbeat_activity" | "thread_reply"; + projectUnderstandingReason?: "heartbeat_activity" | "thread_reply" | "manual_device_sync"; status: MasterAgentTaskStatus; requestedAt: string; claimedAt?: string; @@ -2995,7 +2995,9 @@ function normalizeState(raw: Partial | undefined): BossState { deviceImportCandidateFolderName: task.deviceImportCandidateFolderName, projectUnderstandingTargetProjectId: task.projectUnderstandingTargetProjectId, projectUnderstandingReason: - task.projectUnderstandingReason === "heartbeat_activity" || task.projectUnderstandingReason === "thread_reply" + task.projectUnderstandingReason === "heartbeat_activity" || + task.projectUnderstandingReason === "thread_reply" || + task.projectUnderstandingReason === "manual_device_sync" ? task.projectUnderstandingReason : undefined, status: task.status ?? "queued", @@ -5205,7 +5207,7 @@ export async function queueMasterAgentTask(payload: { deviceImportCandidateId?: string; deviceImportCandidateFolderName?: string; projectUnderstandingTargetProjectId?: string; - projectUnderstandingReason?: "heartbeat_activity" | "thread_reply"; + projectUnderstandingReason?: "heartbeat_activity" | "thread_reply" | "manual_device_sync"; }) { const task = await mutateState((state) => { const task: MasterAgentTask = { @@ -7354,7 +7356,12 @@ function applyProjectUnderstandingSnapshotInState( return snapshot; } -function shouldQueueProjectUnderstandingSync(project: Project, observedActivityAt: string, state: BossState) { +function shouldQueueProjectUnderstandingSync( + project: Project, + observedActivityAt: string, + state: BossState, + options?: { force?: boolean }, +) { if (!isDispatchableThreadProject(project)) { return false; } @@ -7362,14 +7369,16 @@ function shouldQueueProjectUnderstandingSync(project: Project, observedActivityA if (!Number.isFinite(observedTs)) { return false; } - const latestWatermark = Date.parse( - project.threadMeta.lastProjectUnderstandingRequestedAt ?? - project.threadMeta.lastProjectUnderstandingSyncedAt ?? - project.projectUnderstanding?.updatedAt ?? - "1970-01-01T00:00:00.000Z", - ); - if (Number.isFinite(latestWatermark) && observedTs <= latestWatermark) { - return false; + if (!options?.force) { + const latestWatermark = Date.parse( + project.threadMeta.lastProjectUnderstandingRequestedAt ?? + project.threadMeta.lastProjectUnderstandingSyncedAt ?? + project.projectUnderstanding?.updatedAt ?? + "1970-01-01T00:00:00.000Z", + ); + if (Number.isFinite(latestWatermark) && observedTs <= latestWatermark) { + return false; + } } return !state.masterAgentTasks.some( (task) => @@ -7380,13 +7389,22 @@ function shouldQueueProjectUnderstandingSync(project: Project, observedActivityA ); } -function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbeat_activity" | "thread_reply") { +function buildProjectUnderstandingSyncPrompt( + project: Project, + reason: "heartbeat_activity" | "thread_reply" | "manual_device_sync", +) { return [ "你正在向主 Agent 同步当前项目状态。", `项目名称:${project.name}`, `线程名称:${project.threadMeta.threadDisplayName}`, `文件夹:${project.threadMeta.folderName}`, - `同步原因:${reason === "heartbeat_activity" ? "检测到线程有新活动" : "线程刚刚产生了新的执行结果"}`, + `同步原因:${ + reason === "heartbeat_activity" + ? "检测到线程有新活动" + : reason === "thread_reply" + ? "线程刚刚产生了新的执行结果" + : "用户主动要求同步当前设备上的活跃项目理解" + }`, "", "只输出 JSON,不要输出解释性文字或 Markdown。", "JSON 结构固定为:", @@ -7402,14 +7420,17 @@ function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbea async function queueProjectUnderstandingSyncTask(input: { projectId: string; observedActivityAt: string; - reason: "heartbeat_activity" | "thread_reply"; + reason: "heartbeat_activity" | "thread_reply" | "manual_device_sync"; + force?: boolean; + requestedByAccount?: string; }) { const state = await readState(); const project = state.projects.find((item) => item.id === input.projectId); - if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state)) { + if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state, { force: input.force })) { return null; } - const requestedByAccount = state.user.account || project.deviceIds[0] || "17600003315"; + const requestedByAccount = + input.requestedByAccount?.trim() || state.user.account || project.deviceIds[0] || "17600003315"; const task = await queueMasterAgentTask({ projectId: "master-agent", taskType: "conversation_reply", @@ -7442,6 +7463,82 @@ async function queueProjectUnderstandingSyncTask(input: { return task; } +export async function syncDeviceProjectUnderstanding(input: { + deviceId: string; + requestedByAccount: string; + limit?: number; +}) { + const state = await readState(); + const device = state.devices.find((item) => item.id === input.deviceId); + if (!device) { + throw new Error("DEVICE_NOT_FOUND"); + } + + const activeProjects = state.projects + .filter( + (project) => + !project.isGroup && + project.deviceIds.includes(input.deviceId) && + isDispatchableThreadProject(project) && + Boolean(project.threadMeta.codexThreadRef?.trim()), + ) + .sort((left, right) => + String( + right.threadMeta.lastObservedCodexActivityAt ?? + right.projectUnderstanding?.updatedAt ?? + right.lastMessageAt, + ).localeCompare( + String( + left.threadMeta.lastObservedCodexActivityAt ?? + left.projectUnderstanding?.updatedAt ?? + left.lastMessageAt, + ), + ), + ) + .slice(0, Math.max(1, input.limit ?? 3)); + + const queuedTasks = []; + for (const project of activeProjects) { + const observedActivityAt = + project.threadMeta.lastObservedCodexActivityAt ?? + project.projectUnderstanding?.updatedAt ?? + project.lastMessageAt ?? + nowIso(); + const task = await queueProjectUnderstandingSyncTask({ + projectId: project.id, + observedActivityAt, + reason: "manual_device_sync", + force: true, + requestedByAccount: input.requestedByAccount, + }); + if (task) { + queuedTasks.push({ + taskId: task.taskId, + projectId: project.id, + projectName: project.name, + threadDisplayName: project.threadMeta.threadDisplayName, + }); + } + } + + return { + ok: true as const, + deviceId: device.id, + deviceName: device.name, + queuedTasks, + activeProjects: activeProjects.map((project) => ({ + projectId: project.id, + projectName: project.name, + threadDisplayName: project.threadMeta.threadDisplayName, + lastObservedCodexActivityAt: + project.threadMeta.lastObservedCodexActivityAt ?? + project.projectUnderstanding?.updatedAt ?? + project.lastMessageAt, + currentProgress: project.projectUnderstanding?.currentProgress ?? "", + })), + }; +} + export async function previewDeviceImportResolution(input: { deviceId: string }) { const state = await readState(); const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId); diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts index ed1a134..a24c2b2 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -13,6 +13,7 @@ let selectImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId] let reviewImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/review/route"))["POST"]; let completeMasterTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"]; let applyImportDraftRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route"))["POST"]; +let syncDeviceProjectUnderstandingRoute: (typeof import("../src/app/api/v1/devices/[deviceId]/project-understanding-sync/route"))["POST"]; let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; let readState: (typeof import("../src/lib/boss-data"))["readState"]; let AUTH_SESSION_COOKIE = ""; @@ -24,7 +25,7 @@ async function setup() { process.env.BOSS_RUNTIME_ROOT = runtimeRoot; process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); - const [enrollmentModule, heartbeatModule, importDraftModule, selectModule, reviewModule, completeModule, applyModule, data, auth] = + const [enrollmentModule, heartbeatModule, importDraftModule, selectModule, reviewModule, completeModule, applyModule, syncModule, data, auth] = await Promise.all([ import("../src/app/api/v1/devices/enrollments/route.ts"), import("../src/app/api/device-heartbeat/route.ts"), @@ -33,6 +34,7 @@ async function setup() { import("../src/app/api/v1/devices/[deviceId]/import-draft/review/route.ts"), import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts"), import("../src/app/api/v1/devices/[deviceId]/import-draft/apply/route.ts"), + import("../src/app/api/v1/devices/[deviceId]/project-understanding-sync/route.ts"), import("../src/lib/boss-data.ts"), import("../src/lib/boss-auth.ts"), ]); @@ -44,6 +46,7 @@ async function setup() { reviewImportDraftRoute = reviewModule.POST; completeMasterTaskRoute = completeModule.POST; applyImportDraftRoute = applyModule.POST; + syncDeviceProjectUnderstandingRoute = syncModule.POST; createAuthSession = data.createAuthSession; readState = data.readState; AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; @@ -611,6 +614,220 @@ test("imported thread projects queue hidden understanding sync tasks on newer ac ); }); +test("existing imported devices can manually trigger project understanding sync from the device route", async () => { + await setup(); + + const enrollmentResponse = await createEnrollmentRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", { + name: "Mac Studio Existing", + avatar: "E", + account: "17600003315", + endpoint: "mac://existing.local", + note: "manual project sync", + }), + ); + 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: "Mac Studio Existing", + avatar: "E", + account: "17600003315", + status: "online", + quota5h: 69, + quota7d: 86, + projects: [], + endpoint: "mac://existing.local", + projectCandidates: [ + { + folderName: "视觉控制台", + folderRef: "vision-console", + threadId: "thread-vision-console", + threadDisplayName: "视觉控制台主线程", + codexFolderRef: "vision-console", + codexThreadRef: "thread-vision-console", + lastActiveAt: "2026-03-30T12:00:00+08:00", + suggestedImport: true, + }, + ], + }), + }) + ) + ).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, + ); + + let currentState = await readState(); + const reviewTask = currentState.masterAgentTasks.find( + (task) => + task.taskType === "device_import_resolution" && + task.deviceImportDraftId && + task.status === "queued", + ); + const understandingTask = currentState.masterAgentTasks.find( + (task) => + task.taskType === "conversation_reply" && + task.deviceImportDraftId && + task.deviceImportCandidateId && + task.status === "queued", + ); + assert.ok(reviewTask); + assert.ok(understandingTask); + + const reviewCompleteResponse = await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${reviewTask?.taskId}/complete`, + "POST", + { + deviceId: reviewTask?.deviceId, + status: "completed", + replyBody: JSON.stringify( + { + summary: "Mac Studio Existing 导入建议:将视觉控制台主线程导入为独立会话。", + items: selectedCandidateIds.map((candidateId) => ({ + candidateId, + action: "create_thread_conversation", + reason: "需要保留独立上下文,建议新建会话。", + })), + }, + null, + 2, + ), + }, + ), + { params: Promise.resolve({ taskId: reviewTask?.taskId ?? "" }) }, + ); + const reviewCompleteRaw = await reviewCompleteResponse.text(); + if (reviewCompleteResponse.status !== 200) { + assert.fail(reviewCompleteRaw); + } + + assert.equal( + ( + await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${understandingTask?.taskId}/complete`, + "POST", + { + deviceId: enrollmentPayload.device.id, + status: "completed", + replyBody: JSON.stringify( + { + projectGoal: "完成视觉控制台与设备实时状态打通。", + currentProgress: "已完成导入,当前项目处于持续联调阶段。", + technicalArchitecture: "Android 前端通过 Boss Web 与 local-agent 对接活跃 Codex 线程。", + currentBlockers: "视觉状态回流还不够实时。", + recommendedNextStep: "继续压缩状态同步延迟。", + }, + null, + 2, + ), + }, + ), + { params: Promise.resolve({ taskId: understandingTask?.taskId ?? "" }) }, + ) + ).status, + 200, + ); + + assert.equal( + ( + await applyImportDraftRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/import-draft/apply`, + "POST", + {}, + ), + { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, + ) + ).status, + 200, + ); + + const syncResponse = await syncDeviceProjectUnderstandingRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/devices/${enrollmentPayload.device.id}/project-understanding-sync`, + "POST", + {}, + ), + { params: Promise.resolve({ deviceId: enrollmentPayload.device.id }) }, + ); + const syncRaw = await syncResponse.text(); + if (syncResponse.status !== 200) { + assert.fail(syncRaw); + } + const syncPayload = JSON.parse(syncRaw) as { + queuedTasks?: Array<{ projectId: string; threadDisplayName: string }>; + }; + assert.equal(syncPayload.queuedTasks?.length, 1); + assert.equal(syncPayload.queuedTasks?.[0]?.threadDisplayName, "视觉控制台主线程"); + + currentState = await readState(); + const importedProject = currentState.projects.find( + (project) => project.threadMeta.codexThreadRef === "thread-vision-console", + ); + const manualSyncTask = currentState.masterAgentTasks.find( + (task) => + task.taskType === "conversation_reply" && + task.projectId === "master-agent" && + task.projectUnderstandingTargetProjectId === importedProject?.id && + task.projectUnderstandingReason === "manual_device_sync" && + task.status === "queued", + ); + assert.ok(manualSyncTask, "expected manual device sync route to queue a hidden understanding task"); +}); + test("heartbeat candidates no longer auto-create chat windows from legacy projects when import draft is present", async () => { await setup();