From 01f438e3afdef9e691362dec9dbb3fc497dfb620 Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 4 Apr 2026 08:09:47 +0800 Subject: [PATCH] feat: keep project understanding in sync after import --- src/lib/boss-data.ts | 347 +++++++++++++++++++++++++++++- src/lib/boss-master-agent.ts | 25 +++ tests/device-import-draft.test.ts | 275 +++++++++++++++++++++++ 3 files changed, 640 insertions(+), 7 deletions(-) diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 4aaef40..1e6224a 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -287,6 +287,9 @@ export interface ThreadConversationMeta { folderName: string; activityIconCount: number; updatedAt: string; + lastObservedCodexActivityAt?: string; + lastProjectUnderstandingRequestedAt?: string; + lastProjectUnderstandingSyncedAt?: string; codexThreadRef?: string; codexFolderRef?: string; } @@ -321,6 +324,7 @@ export interface Project { riskLevel: RiskLevel; contextBudgetPct?: number; contextBudgetLabel?: string; + projectUnderstanding?: ProjectUnderstandingSnapshot; messages: Message[]; goals: GoalItem[]; versions: VersionEntry[]; @@ -495,6 +499,17 @@ export interface DeviceImportProjectUnderstanding { updatedAt: string; } +export interface ProjectUnderstandingSnapshot { + projectGoal: string; + currentProgress: string; + technicalArchitecture: string; + currentBlockers: string; + recommendedNextStep: string; + sourceTaskId: string; + updatedAt: string; + sourceKind: "device_import" | "thread_sync"; +} + export interface VerificationCode { id: string; account: string; @@ -661,6 +676,8 @@ export interface MasterAgentTask { deviceImportDraftId?: string; deviceImportCandidateId?: string; deviceImportCandidateFolderName?: string; + projectUnderstandingTargetProjectId?: string; + projectUnderstandingReason?: "heartbeat_activity" | "thread_reply"; status: MasterAgentTaskStatus; requestedAt: string; claimedAt?: string; @@ -1724,6 +1741,11 @@ function normalizeThreadMeta( folderName: raw?.folderName ?? fallback?.folderName ?? (project.isGroup ? "群聊" : project.name), activityIconCount: Math.max(1, raw?.activityIconCount ?? fallback?.activityIconCount ?? (project.isGroup ? 2 : 1)), updatedAt: raw?.updatedAt ?? project.updatedAt ?? nowIso(), + lastObservedCodexActivityAt: raw?.lastObservedCodexActivityAt ?? fallback?.lastObservedCodexActivityAt, + lastProjectUnderstandingRequestedAt: + raw?.lastProjectUnderstandingRequestedAt ?? fallback?.lastProjectUnderstandingRequestedAt, + lastProjectUnderstandingSyncedAt: + raw?.lastProjectUnderstandingSyncedAt ?? fallback?.lastProjectUnderstandingSyncedAt, codexThreadRef: raw?.codexThreadRef, codexFolderRef: raw?.codexFolderRef, }; @@ -2783,6 +2805,7 @@ function normalizeProject(raw: Partial, fallback?: Project): Project { unreadCount: typeof raw.unreadCount === "number" ? raw.unreadCount : base.unreadCount ?? 0, riskLevel: raw.riskLevel ?? base.riskLevel ?? "low", + projectUnderstanding: normalizeProjectUnderstanding(raw.projectUnderstanding, fallback?.projectUnderstanding), messages: ensureArray(raw.messages, base.messages).map(normalizeMessage), goals: ensureArray(raw.goals, base.goals).map((goal) => ({ id: goal.id ?? randomToken("goal"), @@ -2813,6 +2836,33 @@ function normalizeProject(raw: Partial, fallback?: Project): Project { return project; } +function normalizeProjectUnderstanding( + raw: Partial | undefined, + fallback?: ProjectUnderstandingSnapshot, +): ProjectUnderstandingSnapshot | undefined { + if (!raw && !fallback) { + return undefined; + } + const projectGoal = raw?.projectGoal?.trim() ?? fallback?.projectGoal?.trim() ?? ""; + const currentProgress = raw?.currentProgress?.trim() ?? fallback?.currentProgress?.trim() ?? ""; + const technicalArchitecture = raw?.technicalArchitecture?.trim() ?? fallback?.technicalArchitecture?.trim() ?? ""; + const currentBlockers = raw?.currentBlockers?.trim() ?? fallback?.currentBlockers?.trim() ?? ""; + const recommendedNextStep = raw?.recommendedNextStep?.trim() ?? fallback?.recommendedNextStep?.trim() ?? ""; + if (!projectGoal && !currentProgress && !technicalArchitecture && !currentBlockers && !recommendedNextStep) { + return undefined; + } + return { + projectGoal, + currentProgress, + technicalArchitecture, + currentBlockers, + recommendedNextStep, + sourceTaskId: raw?.sourceTaskId ?? fallback?.sourceTaskId ?? randomToken("mastertask"), + updatedAt: raw?.updatedAt ?? fallback?.updatedAt ?? nowIso(), + sourceKind: raw?.sourceKind ?? fallback?.sourceKind ?? "thread_sync", + }; +} + function normalizeState(raw: Partial | undefined): BossState { const base = cloneInitialState(); if (!raw) return syncDerivedState(base); @@ -2943,6 +2993,11 @@ function normalizeState(raw: Partial | undefined): BossState { deviceImportDraftId: task.deviceImportDraftId, deviceImportCandidateId: task.deviceImportCandidateId, deviceImportCandidateFolderName: task.deviceImportCandidateFolderName, + projectUnderstandingTargetProjectId: task.projectUnderstandingTargetProjectId, + projectUnderstandingReason: + task.projectUnderstandingReason === "heartbeat_activity" || task.projectUnderstandingReason === "thread_reply" + ? task.projectUnderstandingReason + : undefined, status: task.status ?? "queued", requestedAt: task.requestedAt ?? nowIso(), claimedAt: task.claimedAt, @@ -5149,6 +5204,8 @@ export async function queueMasterAgentTask(payload: { orchestrationBackendLabel?: string; deviceImportCandidateId?: string; deviceImportCandidateFolderName?: string; + projectUnderstandingTargetProjectId?: string; + projectUnderstandingReason?: "heartbeat_activity" | "thread_reply"; }) { const task = await mutateState((state) => { const task: MasterAgentTask = { @@ -5180,6 +5237,8 @@ export async function queueMasterAgentTask(payload: { orchestrationBackendLabel: payload.orchestrationBackendLabel, deviceImportCandidateId: payload.deviceImportCandidateId, deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName, + projectUnderstandingTargetProjectId: payload.projectUnderstandingTargetProjectId, + projectUnderstandingReason: payload.projectUnderstandingReason, status: "queued", requestedAt: nowIso(), }; @@ -6139,6 +6198,10 @@ export async function completeMasterAgentTask(payload: { task.taskType === "conversation_reply" && task.projectId === "master-agent" && Boolean(task.deviceImportDraftId && task.deviceImportCandidateId); + const isProjectUnderstandingSync = + task.taskType === "conversation_reply" && + task.projectId === "master-agent" && + Boolean(task.projectUnderstandingTargetProjectId); const isThreadConversationReply = task.taskType === "conversation_reply" && task.projectId !== "master-agent" && @@ -6148,6 +6211,18 @@ export async function completeMasterAgentTask(payload: { if (draft) { publishBossEvent("devices.updated", { deviceId: draft.deviceId }); } + } else if (isProjectUnderstandingSync) { + const understanding = parseStructuredProjectUnderstandingReply(task); + if (understanding && task.projectUnderstandingTargetProjectId) { + applyProjectUnderstandingSnapshotInState(state, { + projectId: task.projectUnderstandingTargetProjectId, + account: task.requestedByAccount, + snapshot: understanding, + sourceMessageId: task.requestMessageId, + sourceKind: "thread_sync", + }); + publishBossEvent("conversation.updated", { projectId: task.projectUnderstandingTargetProjectId }); + } } else if (isThreadConversationReply) { const threadProject = state.projects.find( (item) => item.id === (task.targetProjectId ?? task.projectId), @@ -6778,6 +6853,11 @@ export async function upsertDeviceHeartbeat(payload: { }>; }) { const result = await mutateState((state) => { + const projectUnderstandingSyncRequests: Array<{ + projectId: string; + observedActivityAt: string; + reason: "heartbeat_activity"; + }> = []; const existingDevice = state.devices.find((item) => item.id === payload.deviceId) ?? null; const claimedEnrollment = claimEnrollment( state, @@ -6881,6 +6961,30 @@ export async function upsertDeviceHeartbeat(payload: { candidates: normalizedCandidates, }); + for (const candidate of normalizedCandidates) { + const matchingProject = state.projects.find( + (project) => + !project.isGroup && + project.deviceIds.includes(payload.deviceId) && + ((candidate.codexThreadRef && project.threadMeta.codexThreadRef === candidate.codexThreadRef) || + project.threadMeta.threadId === candidate.threadId), + ); + if (!matchingProject) { + continue; + } + matchingProject.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp( + matchingProject.threadMeta.lastObservedCodexActivityAt, + candidate.lastActiveAt, + ) ?? candidate.lastActiveAt; + if (shouldQueueProjectUnderstandingSync(matchingProject, candidate.lastActiveAt, state)) { + projectUnderstandingSyncRequests.push({ + projectId: matchingProject.id, + observedActivityAt: candidate.lastActiveAt, + reason: "heartbeat_activity", + }); + } + } + if ( draft && shouldAutoSyncHeartbeatCandidates({ @@ -6931,8 +7035,12 @@ export async function upsertDeviceHeartbeat(payload: { token: claimedEnrollment?.token ?? device.token, pairingStatus: claimedEnrollment?.status, importDraft: draft, + projectUnderstandingSyncRequests, }; }); + for (const request of result.projectUnderstandingSyncRequests ?? []) { + await queueProjectUnderstandingSyncTask(request); + } publishBossEvent("devices.updated", { deviceId: payload.deviceId }); publishBossEvent("conversation.updated", { deviceId: payload.deviceId }); return result; @@ -7076,9 +7184,25 @@ function listDeviceImportUnderstandingTasks(state: BossState, draftId: string) { function parseDeviceImportUnderstandingReply( task: Pick, ): DeviceImportProjectUnderstanding | null { + const understanding = parseStructuredProjectUnderstandingReply(task); const candidateId = task.deviceImportCandidateId?.trim(); + if (!candidateId || !understanding) { + return null; + } + + return { + candidateId, + threadDisplayName: task.targetThreadDisplayName?.trim() || "未命名线程", + folderName: task.deviceImportCandidateFolderName?.trim() || "", + ...understanding, + }; +} + +function parseStructuredProjectUnderstandingReply( + task: Pick, +): ProjectUnderstandingSnapshot | null { const replyBody = task.replyBody?.trim(); - if (!candidateId || !replyBody) { + if (!replyBody) { return null; } @@ -7109,9 +7233,6 @@ function parseDeviceImportUnderstandingReply( } return { - candidateId, - threadDisplayName: task.targetThreadDisplayName?.trim() || "未命名线程", - folderName: task.deviceImportCandidateFolderName?.trim() || "", projectGoal, currentProgress, technicalArchitecture, @@ -7119,6 +7240,7 @@ function parseDeviceImportUnderstandingReply( recommendedNextStep, sourceTaskId: task.taskId, updatedAt: task.completedAt ?? task.requestedAt, + sourceKind: "thread_sync", }; } @@ -7145,6 +7267,181 @@ function deriveDeviceImportProjectUnderstandings(state: BossState, draftId: stri return [...latestByCandidate.values()]; } +function applyProjectUnderstandingSnapshotInState( + state: BossState, + input: { + projectId: string; + account: string; + snapshot: ProjectUnderstandingSnapshot; + sourceMessageId?: string; + sourceKind: ProjectUnderstandingSnapshot["sourceKind"]; + }, +) { + const project = state.projects.find((item) => item.id === input.projectId); + if (!project) { + return null; + } + const snapshot: ProjectUnderstandingSnapshot = { + ...input.snapshot, + sourceKind: input.sourceKind, + }; + project.projectUnderstanding = snapshot; + project.threadMeta.lastProjectUnderstandingSyncedAt = snapshot.updatedAt; + project.threadMeta.lastObservedCodexActivityAt = + latestIsoTimestamp(project.threadMeta.lastObservedCodexActivityAt, snapshot.updatedAt) ?? snapshot.updatedAt; + + const tags = [project.name, project.threadMeta.threadDisplayName].filter(Boolean); + if (snapshot.projectGoal) { + upsertAutoMasterMemoryInState(state, { + account: input.account, + scope: "project", + projectId: project.id, + title: `项目目标 · ${project.name}`, + content: snapshot.projectGoal, + memoryType: "project_progress", + tags: [...tags, "项目目标"], + sourceMessageId: input.sourceMessageId, + }); + } + if (snapshot.currentProgress) { + upsertAutoMasterMemoryInState(state, { + account: input.account, + scope: "project", + projectId: project.id, + title: `项目进度 · ${project.name}`, + content: snapshot.currentProgress, + memoryType: "project_progress", + tags: [...tags, "项目进度"], + sourceMessageId: input.sourceMessageId, + }); + } + if (snapshot.technicalArchitecture) { + upsertAutoMasterMemoryInState(state, { + account: input.account, + scope: "project", + projectId: project.id, + title: `技术架构 · ${project.name}`, + content: snapshot.technicalArchitecture, + memoryType: "research_note", + tags: [...tags, "技术架构"], + sourceMessageId: input.sourceMessageId, + }); + } + if (snapshot.currentBlockers) { + upsertAutoMasterMemoryInState(state, { + account: input.account, + scope: "project", + projectId: project.id, + title: `当前阻塞 · ${project.name}`, + content: snapshot.currentBlockers, + memoryType: "blocking_issue", + tags: [...tags, "阻塞"], + sourceMessageId: input.sourceMessageId, + }); + } + if (snapshot.recommendedNextStep) { + upsertAutoMasterMemoryInState(state, { + account: input.account, + scope: "project", + projectId: project.id, + title: `下一步建议 · ${project.name}`, + content: snapshot.recommendedNextStep, + memoryType: "workflow_rule", + tags: [...tags, "下一步"], + sourceMessageId: input.sourceMessageId, + }); + } + return snapshot; +} + +function shouldQueueProjectUnderstandingSync(project: Project, observedActivityAt: string, state: BossState) { + if (!isDispatchableThreadProject(project)) { + return false; + } + const observedTs = Date.parse(observedActivityAt); + 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; + } + return !state.masterAgentTasks.some( + (task) => + task.taskType === "conversation_reply" && + task.projectId === "master-agent" && + task.projectUnderstandingTargetProjectId === project.id && + (task.status === "queued" || task.status === "running"), + ); +} + +function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbeat_activity" | "thread_reply") { + return [ + "你正在向主 Agent 同步当前项目状态。", + `项目名称:${project.name}`, + `线程名称:${project.threadMeta.threadDisplayName}`, + `文件夹:${project.threadMeta.folderName}`, + `同步原因:${reason === "heartbeat_activity" ? "检测到线程有新活动" : "线程刚刚产生了新的执行结果"}`, + "", + "只输出 JSON,不要输出解释性文字或 Markdown。", + "JSON 结构固定为:", + '{ "projectGoal": "一句中文目标", "currentProgress": "一句中文进度", "technicalArchitecture": "一句中文架构说明", "currentBlockers": "一句中文阻塞说明", "recommendedNextStep": "一句中文建议动作" }', + "", + "要求:", + "1. 只写当前项目最重要、对主 Agent 接手有帮助的事实。", + "2. 不要重复内部字段、线程编号、目录路径、设备 ID。", + "3. 如果某个字段暂时不清楚,填空字符串。", + ].join("\n"); +} + +async function queueProjectUnderstandingSyncTask(input: { + projectId: string; + observedActivityAt: 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)) { + return null; + } + const requestedByAccount = state.user.account || project.deviceIds[0] || "17600003315"; + const task = await queueMasterAgentTask({ + projectId: "master-agent", + taskType: "conversation_reply", + requestMessageId: randomToken("project-understanding"), + requestText: `请同步项目《${project.name}》当前目标与进度`, + executionPrompt: buildProjectUnderstandingSyncPrompt(project, input.reason), + requestedBy: requestedByAccount, + requestedByAccount, + deviceId: project.deviceIds[0] || state.user.boundDeviceId || "mac-studio", + targetProjectId: project.id, + targetThreadId: project.threadMeta.threadId, + targetThreadDisplayName: project.threadMeta.threadDisplayName, + targetCodexThreadRef: project.threadMeta.codexThreadRef, + targetCodexFolderRef: project.threadMeta.codexFolderRef, + projectUnderstandingTargetProjectId: project.id, + projectUnderstandingReason: input.reason, + }); + await mutateStateIfChanged((freshState) => { + const freshProject = freshState.projects.find((item) => item.id === input.projectId); + if (!freshProject) { + return { result: null, changed: false }; + } + freshProject.threadMeta.lastProjectUnderstandingRequestedAt = task.requestedAt; + freshProject.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp( + freshProject.threadMeta.lastObservedCodexActivityAt, + input.observedActivityAt, + ) ?? input.observedActivityAt; + return { result: null, changed: true }; + }); + return task; +} + export async function previewDeviceImportResolution(input: { deviceId: string }) { const state = await readState(); const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId); @@ -7477,6 +7774,9 @@ function applyDeviceImportResolutionInState( const selectedCandidates = draft.candidates.filter((candidate) => draft.selectedCandidateIds.includes(candidate.candidateId), ); + const understandingsByCandidate = new Map( + deriveDeviceImportProjectUnderstandings(state, draft.draftId).map((item) => [item.candidateId, item] as const), + ); const importedProjects: Project[] = []; for (const item of resolution.items) { const candidate = draft.candidates.find((entry) => entry.candidateId === item.candidateId); @@ -7517,9 +7817,29 @@ function applyDeviceImportResolutionInState( targetProject.threadMeta.codexFolderRef = candidate.codexFolderRef ?? candidate.folderRef; targetProject.threadMeta.codexThreadRef = candidate.codexThreadRef; targetProject.threadMeta.updatedAt = candidate.lastActiveAt; + targetProject.threadMeta.lastObservedCodexActivityAt = candidate.lastActiveAt; targetProject.preview = `已导入 ${candidate.threadDisplayName}`; targetProject.updatedAt = nowIso(); targetProject.lastMessageAt = targetProject.updatedAt; + const understanding = understandingsByCandidate.get(candidate.candidateId); + if (understanding) { + applyProjectUnderstandingSnapshotInState(state, { + projectId: targetProject.id, + account: device.account, + snapshot: { + projectGoal: understanding.projectGoal, + currentProgress: understanding.currentProgress, + technicalArchitecture: understanding.technicalArchitecture, + currentBlockers: understanding.currentBlockers, + recommendedNextStep: understanding.recommendedNextStep, + sourceTaskId: understanding.sourceTaskId, + updatedAt: understanding.updatedAt, + sourceKind: "device_import", + }, + sourceMessageId: understanding.sourceTaskId, + sourceKind: "device_import", + }); + } importedProjects.push({ ...targetProject }); } @@ -7959,7 +8279,7 @@ export async function appendProjectMessage(payload: { kind?: MessageKind; attachments?: MessageAttachment[]; }) { - const message = await mutateState((state) => { + const result = await mutateState((state) => { const project = state.projects.find((item) => item.id === payload.projectId); if (!project) throw new Error("PROJECT_NOT_FOUND"); @@ -8011,11 +8331,24 @@ export async function appendProjectMessage(payload: { project.lastMessageAt = message.sentAt; project.preview = message.body; - return message; + return { + message, + shouldQueueUnderstandingSync: + payload.sender !== "user" && + isDispatchableThreadProject(project) && + Boolean(project.threadMeta.codexThreadRef?.trim()), + }; }); + if (result.shouldQueueUnderstandingSync) { + await queueProjectUnderstandingSyncTask({ + projectId: payload.projectId, + observedActivityAt: result.message.sentAt, + reason: "thread_reply", + }); + } publishBossEvent("project.messages.updated", { projectId: payload.projectId }); publishBossEvent("conversation.updated", { projectId: payload.projectId }); - return message; + return result.message; } export async function appendAttachmentMessage(payload: { diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 67b3bb9..7e58d5a 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -246,6 +246,28 @@ function buildRuntimeDigest( .filter((update) => update.status === "available") .map((update) => `${update.version} -> ${update.targetScope}`) .join("\n"); + const activeProjectUnderstandings = state.projects + .filter((project) => project.id !== "master-agent" && project.projectUnderstanding) + .sort((left, right) => + String(right.projectUnderstanding?.updatedAt ?? right.lastMessageAt).localeCompare( + String(left.projectUnderstanding?.updatedAt ?? left.lastMessageAt), + ), + ) + .slice(0, 3) + .map((project) => { + const understanding = project.projectUnderstanding!; + return [ + `${project.name}:`, + understanding.projectGoal ? `目标=${understanding.projectGoal}` : undefined, + understanding.currentProgress ? `进度=${understanding.currentProgress}` : undefined, + understanding.technicalArchitecture ? `架构=${understanding.technicalArchitecture}` : undefined, + understanding.currentBlockers ? `阻塞=${understanding.currentBlockers}` : undefined, + understanding.recommendedNextStep ? `下一步=${understanding.recommendedNextStep}` : undefined, + ] + .filter(Boolean) + .join(" / "); + }) + .join("\n"); const authSummary = [ `登录会话策略:成功登录后默认保持 ${Math.round(AUTH_SESSION_TTL_MS / 24 / 60 / 60_000)} 天。`, @@ -265,6 +287,9 @@ function buildRuntimeDigest( "最新 APP 日志:", recentLogs || "无", "", + "活跃项目理解:", + activeProjectUnderstandings || "无", + "", "高风险线程:", riskyThreads || "无", "", diff --git a/tests/device-import-draft.test.ts b/tests/device-import-draft.test.ts index d8088eb..ed1a134 100644 --- a/tests/device-import-draft.test.ts +++ b/tests/device-import-draft.test.ts @@ -321,6 +321,23 @@ test("device import draft review queues a master-agent task, then completion wri assert.ok(importedProject, "expected selected candidate to become a real chat window"); assert.equal(importedProject?.threadMeta.threadDisplayName, "北区试产线回归"); assert.equal(importedProject?.threadMeta.folderName, "北区试产线"); + assert.equal(importedProject?.projectUnderstanding?.projectGoal, "完成北区试产线树莓派二代接入与联调。"); + assert.match(importedProject?.projectUnderstanding?.technicalArchitecture ?? "", /local-agent 与 Codex 线程联动/); + assert.equal(importedProject?.projectUnderstanding?.sourceKind, "device_import"); + assert.ok(importedProject?.threadMeta.lastProjectUnderstandingSyncedAt); + + const importedMemories = nextState.masterAgentMemories.filter( + (memory) => memory.projectId === importedProject?.id, + ); + assert.equal(importedMemories.length, 5); + assert.equal( + importedMemories.find((memory) => memory.title === "项目目标 · 北区试产线回归")?.content, + "完成北区试产线树莓派二代接入与联调。", + ); + assert.match( + importedMemories.find((memory) => memory.title === "下一步建议 · 北区试产线回归")?.content ?? "", + /确认接线和串口日志/, + ); const device = nextState.devices.find((item) => item.id === enrollmentPayload.device.id); assert.deepEqual(device?.projects, ["北区试产线"]); @@ -336,6 +353,264 @@ test("device import draft review queues a master-agent task, then completion wri assert.equal(appliedResolution?.status, "applied"); }); +test("imported thread projects queue hidden understanding sync tasks on newer activity and refresh project understanding", async () => { + await setup(); + + const enrollmentResponse = await createEnrollmentRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", { + name: "Mac mini", + avatar: "M", + account: "17600003315", + endpoint: "mac://mini.local", + note: "project sync follow-up", + }), + ); + assert.equal(enrollmentResponse.status, 200); + const enrollmentPayload = (await enrollmentResponse.json()) as { + enrollment: { pairingCode: string }; + device: { id: string }; + }; + + const heartbeatBody = { + deviceId: enrollmentPayload.device.id, + pairingCode: enrollmentPayload.enrollment.pairingCode, + name: "Mac mini", + avatar: "M", + account: "17600003315", + status: "online" as const, + quota5h: 73, + quota7d: 84, + projects: [], + endpoint: "mac://mini.local", + projectCandidates: [ + { + folderName: "智能看板", + folderRef: "smart-board", + threadId: "thread-smart-board", + threadDisplayName: "智能看板主线程", + codexFolderRef: "smart-board", + codexThreadRef: "thread-smart-board", + lastActiveAt: "2026-03-30T11:00:00+08:00", + suggestedImport: true, + }, + ], + }; + + const firstHeartbeatResponse = await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(heartbeatBody), + }), + ); + assert.equal(firstHeartbeatResponse.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, + ); + + const reviewResponse = 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 }) }, + ); + assert.equal(reviewResponse.status, 200); + const reviewPayload = (await reviewResponse.json()) as { + task: { taskId: string; deviceId: string }; + }; + + const stateAfterReview = await readState(); + const initialUnderstandingTask = stateAfterReview.masterAgentTasks.find( + (task) => + task.taskType === "conversation_reply" && + task.deviceImportDraftId && + task.deviceImportCandidateId && + task.status === "queued", + ); + assert.ok(initialUnderstandingTask); + + assert.equal( + ( + await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${reviewPayload.task.taskId}/complete`, + "POST", + { + deviceId: reviewPayload.task.deviceId, + status: "completed", + replyBody: JSON.stringify( + { + summary: "Mac mini 导入建议:将智能看板主线程导入为独立会话。", + items: selectedCandidateIds.map((candidateId) => ({ + candidateId, + action: "create_thread_conversation", + reason: "需要保留独立上下文,建议新建会话。", + })), + }, + null, + 2, + ), + }, + ), + { params: Promise.resolve({ taskId: reviewPayload.task.taskId }) }, + ) + ).status, + 200, + ); + + assert.equal( + ( + await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${initialUnderstandingTask.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: initialUnderstandingTask.taskId }) }, + ) + ).status, + 200, + ); + + const applyResponse = 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 }) }, + ); + assert.equal(applyResponse.status, 200); + + let currentState = await readState(); + const importedProject = currentState.projects.find( + (project) => project.threadMeta.codexThreadRef === "thread-smart-board", + ); + assert.ok(importedProject); + assert.equal(importedProject?.projectUnderstanding?.currentProgress, "已经完成导入前梳理,准备开始界面和设备联调。"); + + const secondHeartbeatResponse = await deviceHeartbeatRoute( + new NextRequest("http://127.0.0.1:3000/api/device-heartbeat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...heartbeatBody, + lastSeenAt: "2026-04-05T11:40:00+08:00", + projectCandidates: [ + { + ...heartbeatBody.projectCandidates[0], + lastActiveAt: "2026-04-05T11:35:00+08:00", + }, + ], + }), + }), + ); + assert.equal(secondHeartbeatResponse.status, 200); + + currentState = await readState(); + const hiddenSyncTask = currentState.masterAgentTasks.find( + (task) => + task.taskType === "conversation_reply" && + task.projectId === "master-agent" && + task.projectUnderstandingTargetProjectId === importedProject?.id && + task.projectUnderstandingReason === "heartbeat_activity" && + task.status === "queued", + ); + assert.ok(hiddenSyncTask, "expected a hidden follow-up sync task for newer thread activity"); + + assert.equal( + ( + await completeMasterTaskRoute( + await createAuthedRequest( + `http://127.0.0.1:3000/api/v1/master-agent/tasks/${hiddenSyncTask.taskId}/complete`, + "POST", + { + deviceId: enrollmentPayload.device.id, + status: "completed", + replyBody: JSON.stringify( + { + projectGoal: "让智能看板项目能够稳定接入主控面板。", + currentProgress: "用户已经继续推进到实时状态同步和 UI 联调阶段。", + technicalArchitecture: "Android 原生端通过 SSE 接收 Boss 更新,local-agent 负责把线程状态回流到控制台。", + currentBlockers: "高刷设备上的 UI 更新仍需继续优化。", + recommendedNextStep: "优先压平实时状态刷新抖动,再验证群聊调度链路。", + }, + null, + 2, + ), + }, + ), + { params: Promise.resolve({ taskId: hiddenSyncTask.taskId }) }, + ) + ).status, + 200, + ); + + currentState = await readState(); + const refreshedProject = currentState.projects.find((project) => project.id === importedProject?.id); + assert.equal(refreshedProject?.projectUnderstanding?.currentProgress, "用户已经继续推进到实时状态同步和 UI 联调阶段。"); + assert.match(refreshedProject?.projectUnderstanding?.technicalArchitecture ?? "", /SSE 接收 Boss 更新/); + assert.equal(refreshedProject?.projectUnderstanding?.sourceKind, "thread_sync"); + assert.ok(refreshedProject?.threadMeta.lastProjectUnderstandingRequestedAt); + assert.ok(refreshedProject?.threadMeta.lastProjectUnderstandingSyncedAt); + + assert.equal( + currentState.masterAgentMemories.find( + (memory) => + memory.projectId === refreshedProject?.id && + memory.title === "项目进度 · 智能看板主线程", + )?.content, + "用户已经继续推进到实时状态同步和 UI 联调阶段。", + ); + assert.equal( + currentState.masterAgentMemories.find( + (memory) => + memory.projectId === refreshedProject?.id && + memory.title === "下一步建议 · 智能看板主线程", + )?.content, + "优先压平实时状态刷新抖动,再验证群聊调度链路。", + ); +}); + test("heartbeat candidates no longer auto-create chat windows from legacy projects when import draft is present", async () => { await setup();