diff --git a/README.md b/README.md index cd814e0..a8539ef 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Android APK: - 当前设备导入主链已补上真实审核闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户提交勾选后会先排队 `device_import_resolution` 主 Agent 任务,前台进入“主 Agent 审核中”并自动刷新,任务完成后才写回正式导入建议,再把选中的线程真正落成聊天窗口 - 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入` - 当前设备导入前台文案与状态卡已收口:会明确显示 `等待候选线程 / 等待勾选 / 建议已生成 / 已导入`,并在导入后回显真正落到会话首页的线程名 -- 当前已导入设备也支持主动同步项目理解:设备详情页新增 `同步项目理解`,会由主 Agent 主动询问这台设备上活跃线程的项目目标、当前进度和技术架构,并把结果回写到项目理解和项目记忆 +- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程本身刚回写新结果时,都会自动排隐藏的主 Agent 理解任务,把项目目标、当前进度和技术架构回写到项目理解和项目记忆 - 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `import draft`,不再绕过勾选/应用阶段直接把旧项目目录导入为聊天窗口 - 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本 @@ -315,7 +315,6 @@ 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 755b24f..6ad2e5d 100644 --- a/android/app/src/main/java/com/hyzq/boss/BossApiClient.java +++ b/android/app/src/main/java/com/hyzq/boss/BossApiClient.java @@ -399,10 +399,6 @@ 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 07bf474..44f99b9 100644 --- a/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java +++ b/android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java @@ -75,7 +75,6 @@ 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); } @@ -94,33 +93,6 @@ 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 index c4c1c19..ea04c1a 100644 --- a/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java +++ b/android/app/src/test/java/com/hyzq/boss/DeviceDetailActivityTest.java @@ -1,7 +1,6 @@ package com.hyzq.boss; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; import android.content.Context; import android.content.Intent; @@ -9,8 +8,6 @@ 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; @@ -18,14 +15,13 @@ 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() { + public void renderDeviceDoesNotShowManualProjectUnderstandingEntry() { TestDeviceDetailActivity activity = Robolectric .buildActivity( TestDeviceDetailActivity.class, @@ -37,30 +33,8 @@ public class DeviceDetailActivityTest { .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()); + assertFalse(viewTreeContainsText(content, "同步项目理解")); + assertFalse(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构")); } private static boolean viewTreeContainsText(View root, String expectedText) { @@ -82,47 +56,10 @@ public class DeviceDetailActivityTest { 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; + this.apiClient = new BossApiClient(getSharedPreferences("test-boss-api", Context.MODE_PRIVATE), "https://boss.hyzq.net"); try { ReflectionHelpers.callInstanceMethod( this, @@ -150,29 +87,4 @@ public class DeviceDetailActivityTest { .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 534c4bc..79dcaba 100644 --- a/docs/architecture/api_and_service_inventory_cn.md +++ b/docs/architecture/api_and_service_inventory_cn.md @@ -763,18 +763,6 @@ - 已绑定的生产设备如果在 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 a5c734d..5a08db7 100644 --- a/docs/architecture/current_runtime_and_deploy_status_cn.md +++ b/docs/architecture/current_runtime_and_deploy_status_cn.md @@ -129,7 +129,7 @@ cd /Users/kris/code/boss - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 - 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名 -- 当前已导入设备也支持主动同步项目理解:设备详情页新增 `同步项目理解`,会直接为这台设备上已导入的活跃线程排隐藏的 `conversation_reply` 主 Agent 任务,回写最新的项目目标、当前进度、技术架构和下一步建议 +- 当前已导入设备也支持自动同步项目理解:绑定设备 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 deleted file mode 100644 index 9682322..0000000 --- a/src/app/api/v1/devices/[deviceId]/project-understanding-sync/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 87c67fa..1e6224a 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" | "manual_device_sync"; + projectUnderstandingReason?: "heartbeat_activity" | "thread_reply"; status: MasterAgentTaskStatus; requestedAt: string; claimedAt?: string; @@ -2995,9 +2995,7 @@ function normalizeState(raw: Partial | undefined): BossState { deviceImportCandidateFolderName: task.deviceImportCandidateFolderName, projectUnderstandingTargetProjectId: task.projectUnderstandingTargetProjectId, projectUnderstandingReason: - task.projectUnderstandingReason === "heartbeat_activity" || - task.projectUnderstandingReason === "thread_reply" || - task.projectUnderstandingReason === "manual_device_sync" + task.projectUnderstandingReason === "heartbeat_activity" || task.projectUnderstandingReason === "thread_reply" ? task.projectUnderstandingReason : undefined, status: task.status ?? "queued", @@ -5207,7 +5205,7 @@ export async function queueMasterAgentTask(payload: { deviceImportCandidateId?: string; deviceImportCandidateFolderName?: string; projectUnderstandingTargetProjectId?: string; - projectUnderstandingReason?: "heartbeat_activity" | "thread_reply" | "manual_device_sync"; + projectUnderstandingReason?: "heartbeat_activity" | "thread_reply"; }) { const task = await mutateState((state) => { const task: MasterAgentTask = { @@ -7356,12 +7354,7 @@ function applyProjectUnderstandingSnapshotInState( return snapshot; } -function shouldQueueProjectUnderstandingSync( - project: Project, - observedActivityAt: string, - state: BossState, - options?: { force?: boolean }, -) { +function shouldQueueProjectUnderstandingSync(project: Project, observedActivityAt: string, state: BossState) { if (!isDispatchableThreadProject(project)) { return false; } @@ -7369,16 +7362,14 @@ function shouldQueueProjectUnderstandingSync( if (!Number.isFinite(observedTs)) { 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; - } + 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) => @@ -7389,22 +7380,13 @@ function shouldQueueProjectUnderstandingSync( ); } -function buildProjectUnderstandingSyncPrompt( - project: Project, - reason: "heartbeat_activity" | "thread_reply" | "manual_device_sync", -) { +function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbeat_activity" | "thread_reply") { return [ "你正在向主 Agent 同步当前项目状态。", `项目名称:${project.name}`, `线程名称:${project.threadMeta.threadDisplayName}`, `文件夹:${project.threadMeta.folderName}`, - `同步原因:${ - reason === "heartbeat_activity" - ? "检测到线程有新活动" - : reason === "thread_reply" - ? "线程刚刚产生了新的执行结果" - : "用户主动要求同步当前设备上的活跃项目理解" - }`, + `同步原因:${reason === "heartbeat_activity" ? "检测到线程有新活动" : "线程刚刚产生了新的执行结果"}`, "", "只输出 JSON,不要输出解释性文字或 Markdown。", "JSON 结构固定为:", @@ -7420,17 +7402,14 @@ function buildProjectUnderstandingSyncPrompt( async function queueProjectUnderstandingSyncTask(input: { projectId: string; observedActivityAt: string; - reason: "heartbeat_activity" | "thread_reply" | "manual_device_sync"; - force?: boolean; - requestedByAccount?: string; + reason: "heartbeat_activity" | "thread_reply"; }) { const state = await readState(); const project = state.projects.find((item) => item.id === input.projectId); - if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state, { force: input.force })) { + if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state)) { return null; } - const requestedByAccount = - input.requestedByAccount?.trim() || state.user.account || project.deviceIds[0] || "17600003315"; + const requestedByAccount = state.user.account || project.deviceIds[0] || "17600003315"; const task = await queueMasterAgentTask({ projectId: "master-agent", taskType: "conversation_reply", @@ -7463,82 +7442,6 @@ 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 a24c2b2..ed1a134 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -13,7 +13,6 @@ 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 = ""; @@ -25,7 +24,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, syncModule, data, auth] = + const [enrollmentModule, heartbeatModule, importDraftModule, selectModule, reviewModule, completeModule, applyModule, data, auth] = await Promise.all([ import("../src/app/api/v1/devices/enrollments/route.ts"), import("../src/app/api/device-heartbeat/route.ts"), @@ -34,7 +33,6 @@ 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"), ]); @@ -46,7 +44,6 @@ 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; @@ -614,220 +611,6 @@ 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();